web-agent-bridge 2.3.1 → 2.5.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/README.ar.md +524 -31
- package/README.md +592 -47
- package/bin/agent-runner.js +10 -1
- package/package.json +1 -1
- package/public/agent-workspace.html +347 -0
- package/public/browser.html +484 -0
- package/public/css/agent-workspace.css +1713 -0
- package/public/index.html +94 -0
- package/public/js/agent-workspace.js +1740 -0
- package/sdk/index.d.ts +253 -0
- package/sdk/index.js +360 -1
- package/sdk/package.json +1 -1
- package/server/config/secrets.js +13 -5
- package/server/control-plane/index.js +301 -0
- package/server/data-plane/index.js +354 -0
- package/server/index.js +185 -4
- package/server/llm/index.js +404 -0
- package/server/middleware/adminAuth.js +6 -1
- package/server/middleware/auth.js +11 -2
- package/server/middleware/rateLimits.js +78 -2
- package/server/migrations/003_ads_integer_cents.sql +33 -0
- package/server/models/db.js +126 -25
- package/server/observability/index.js +394 -0
- package/server/protocol/capabilities.js +223 -0
- package/server/protocol/index.js +243 -0
- package/server/protocol/schema.js +584 -0
- package/server/registry/index.js +326 -0
- package/server/routes/admin.js +16 -2
- package/server/routes/ads.js +130 -0
- package/server/routes/agent-workspace.js +378 -0
- package/server/routes/api.js +21 -2
- package/server/routes/auth.js +26 -6
- package/server/routes/runtime.js +725 -0
- package/server/routes/sovereign.js +78 -0
- package/server/routes/universal.js +177 -0
- package/server/routes/wab-api.js +20 -5
- package/server/runtime/event-bus.js +210 -0
- package/server/runtime/index.js +233 -0
- package/server/runtime/sandbox.js +266 -0
- package/server/runtime/scheduler.js +395 -0
- package/server/runtime/state-manager.js +188 -0
- package/server/security/index.js +355 -0
- package/server/services/agent-chat.js +506 -0
- package/server/services/agent-symphony.js +6 -0
- package/server/services/agent-tasks.js +1807 -0
- package/server/services/fairness-engine.js +409 -0
- package/server/services/plugins.js +27 -3
- package/server/services/price-intelligence.js +565 -0
- package/server/services/price-shield.js +1137 -0
- package/server/services/search-engine.js +357 -0
- package/server/services/security.js +513 -0
- package/server/services/universal-scraper.js +661 -0
- package/server/ws.js +61 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAB Agent Workspace — Server-side Route & API
|
|
3
|
+
* ════════════════════════════════════════════════════════════════════════
|
|
4
|
+
* Premium workspace endpoints for the 4-panel agent workspace.
|
|
5
|
+
* Handles subscriptions, workspace sessions, agent execution, and admin stats.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const express = require('express');
|
|
9
|
+
const router = express.Router();
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { authenticateToken } = require('../middleware/auth');
|
|
12
|
+
const { authenticateAdmin } = require('../middleware/adminAuth');
|
|
13
|
+
const { db } = require('../models/db');
|
|
14
|
+
|
|
15
|
+
// ─── Schema ──────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
db.exec(`
|
|
18
|
+
CREATE TABLE IF NOT EXISTS workspace_subscriptions (
|
|
19
|
+
id TEXT PRIMARY KEY,
|
|
20
|
+
user_id TEXT NOT NULL,
|
|
21
|
+
plan TEXT NOT NULL DEFAULT 'free' CHECK(plan IN ('free','starter','pro','enterprise')),
|
|
22
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','cancelled','expired','suspended')),
|
|
23
|
+
tasks_today INTEGER DEFAULT 0,
|
|
24
|
+
tasks_total INTEGER DEFAULT 0,
|
|
25
|
+
deals_completed INTEGER DEFAULT 0,
|
|
26
|
+
total_savings REAL DEFAULT 0,
|
|
27
|
+
last_task_date TEXT,
|
|
28
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
29
|
+
expires_at TEXT,
|
|
30
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS workspace_sessions (
|
|
34
|
+
id TEXT PRIMARY KEY,
|
|
35
|
+
user_id TEXT NOT NULL,
|
|
36
|
+
session_token TEXT NOT NULL,
|
|
37
|
+
active INTEGER DEFAULT 1,
|
|
38
|
+
panels_state TEXT DEFAULT '{}',
|
|
39
|
+
last_activity TEXT DEFAULT (datetime('now')),
|
|
40
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
41
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS workspace_deals (
|
|
45
|
+
id TEXT PRIMARY KEY,
|
|
46
|
+
user_id TEXT NOT NULL,
|
|
47
|
+
task_id TEXT,
|
|
48
|
+
offer_source TEXT,
|
|
49
|
+
offer_title TEXT,
|
|
50
|
+
original_price REAL,
|
|
51
|
+
final_price REAL,
|
|
52
|
+
savings REAL DEFAULT 0,
|
|
53
|
+
status TEXT DEFAULT 'presented' CHECK(status IN ('presented','clicked','agent_executing','login_required','completed','failed')),
|
|
54
|
+
deal_url TEXT,
|
|
55
|
+
requires_login INTEGER DEFAULT 0,
|
|
56
|
+
login_method TEXT,
|
|
57
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
58
|
+
completed_at TEXT,
|
|
59
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS workspace_analytics (
|
|
63
|
+
id TEXT PRIMARY KEY,
|
|
64
|
+
user_id TEXT,
|
|
65
|
+
event_type TEXT NOT NULL,
|
|
66
|
+
event_data TEXT DEFAULT '{}',
|
|
67
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_ws_subs_user ON workspace_subscriptions(user_id);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_ws_sessions_user ON workspace_sessions(user_id);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_ws_deals_user ON workspace_deals(user_id);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_ws_analytics_type ON workspace_analytics(event_type);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_ws_analytics_date ON workspace_analytics(created_at);
|
|
75
|
+
`);
|
|
76
|
+
|
|
77
|
+
// ─── Plan Limits ─────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
const PLAN_LIMITS = {
|
|
80
|
+
free: { dailyTasks: 5, maxResults: 3, negotiation: false, agentExecute: false },
|
|
81
|
+
starter: { dailyTasks: 30, maxResults: 10, negotiation: true, agentExecute: false },
|
|
82
|
+
pro: { dailyTasks: -1, maxResults: 20, negotiation: true, agentExecute: true },
|
|
83
|
+
enterprise: { dailyTasks: -1, maxResults: 50, negotiation: true, agentExecute: true },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// ─── Prepared Statements ─────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
const stmts = {
|
|
89
|
+
getSub: db.prepare('SELECT * FROM workspace_subscriptions WHERE user_id = ? AND status = ?'),
|
|
90
|
+
insertSub: db.prepare(`INSERT INTO workspace_subscriptions (id, user_id, plan, status) VALUES (?, ?, ?, 'active')`),
|
|
91
|
+
updateSubPlan: db.prepare('UPDATE workspace_subscriptions SET plan = ?, status = ? WHERE user_id = ? AND status = ?'),
|
|
92
|
+
incrementTasks: db.prepare(`UPDATE workspace_subscriptions SET tasks_today = tasks_today + 1, tasks_total = tasks_total + 1, last_task_date = date('now') WHERE user_id = ? AND status = 'active'`),
|
|
93
|
+
resetDailyTasks: db.prepare(`UPDATE workspace_subscriptions SET tasks_today = 0 WHERE last_task_date < date('now')`),
|
|
94
|
+
addDealSavings: db.prepare(`UPDATE workspace_subscriptions SET deals_completed = deals_completed + 1, total_savings = total_savings + ? WHERE user_id = ? AND status = 'active'`),
|
|
95
|
+
|
|
96
|
+
insertSession: db.prepare('INSERT INTO workspace_sessions (id, user_id, session_token) VALUES (?, ?, ?)'),
|
|
97
|
+
getSession: db.prepare('SELECT * FROM workspace_sessions WHERE session_token = ? AND active = 1'),
|
|
98
|
+
endSession: db.prepare("UPDATE workspace_sessions SET active = 0 WHERE session_token = ?"),
|
|
99
|
+
|
|
100
|
+
insertDeal: db.prepare('INSERT INTO workspace_deals (id, user_id, task_id, offer_source, offer_title, original_price, final_price, savings, deal_url, requires_login) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'),
|
|
101
|
+
updateDealStatus: db.prepare('UPDATE workspace_deals SET status = ?, completed_at = datetime(\'now\') WHERE id = ?'),
|
|
102
|
+
getUserDeals: db.prepare('SELECT * FROM workspace_deals WHERE user_id = ? ORDER BY created_at DESC LIMIT ?'),
|
|
103
|
+
|
|
104
|
+
logEvent: db.prepare('INSERT INTO workspace_analytics (id, user_id, event_type, event_data) VALUES (?, ?, ?, ?)'),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Reset daily task counts
|
|
108
|
+
try { stmts.resetDailyTasks.run(); } catch (_) {}
|
|
109
|
+
|
|
110
|
+
// ─── User Routes ─────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* GET /api/workspace/subscription — Get user's workspace subscription
|
|
114
|
+
*/
|
|
115
|
+
router.get('/subscription', authenticateToken, (req, res) => {
|
|
116
|
+
let sub = stmts.getSub.get(req.user.id, 'active');
|
|
117
|
+
|
|
118
|
+
if (!sub) {
|
|
119
|
+
// Auto-create free subscription
|
|
120
|
+
const id = crypto.randomUUID();
|
|
121
|
+
stmts.insertSub.run(id, req.user.id, 'free');
|
|
122
|
+
sub = stmts.getSub.get(req.user.id, 'active');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const limits = PLAN_LIMITS[sub.plan] || PLAN_LIMITS.free;
|
|
126
|
+
const remaining = limits.dailyTasks < 0 ? -1 : Math.max(0, limits.dailyTasks - (sub.tasks_today || 0));
|
|
127
|
+
|
|
128
|
+
res.json({
|
|
129
|
+
plan: sub.plan,
|
|
130
|
+
status: sub.status,
|
|
131
|
+
tasksToday: sub.tasks_today,
|
|
132
|
+
tasksTotal: sub.tasks_total,
|
|
133
|
+
dealsCompleted: sub.deals_completed,
|
|
134
|
+
totalSavings: sub.total_savings,
|
|
135
|
+
limits,
|
|
136
|
+
remainingTasks: remaining,
|
|
137
|
+
createdAt: sub.created_at,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* POST /api/workspace/subscription — Upgrade/change plan
|
|
143
|
+
*/
|
|
144
|
+
router.post('/subscription', authenticateToken, (req, res) => {
|
|
145
|
+
const { plan } = req.body;
|
|
146
|
+
if (!PLAN_LIMITS[plan]) return res.status(400).json({ error: 'Invalid plan' });
|
|
147
|
+
|
|
148
|
+
let sub = stmts.getSub.get(req.user.id, 'active');
|
|
149
|
+
if (sub) {
|
|
150
|
+
stmts.updateSubPlan.run(plan, 'active', req.user.id, 'active');
|
|
151
|
+
} else {
|
|
152
|
+
const id = crypto.randomUUID();
|
|
153
|
+
stmts.insertSub.run(id, req.user.id, plan);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
logEvent(req.user.id, 'plan_changed', { plan });
|
|
157
|
+
res.json({ success: true, plan });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* POST /api/workspace/check-limits — Check if user can execute a task
|
|
162
|
+
*/
|
|
163
|
+
router.post('/check-limits', authenticateToken, (req, res) => {
|
|
164
|
+
const sub = stmts.getSub.get(req.user.id, 'active');
|
|
165
|
+
if (!sub) return res.json({ allowed: true, plan: 'free' }); // New user
|
|
166
|
+
|
|
167
|
+
const limits = PLAN_LIMITS[sub.plan] || PLAN_LIMITS.free;
|
|
168
|
+
if (limits.dailyTasks >= 0 && sub.tasks_today >= limits.dailyTasks) {
|
|
169
|
+
return res.json({
|
|
170
|
+
allowed: false,
|
|
171
|
+
reason: 'daily_limit',
|
|
172
|
+
plan: sub.plan,
|
|
173
|
+
limit: limits.dailyTasks,
|
|
174
|
+
used: sub.tasks_today,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
stmts.incrementTasks.run(req.user.id);
|
|
179
|
+
res.json({ allowed: true, plan: sub.plan, remaining: limits.dailyTasks < 0 ? -1 : limits.dailyTasks - sub.tasks_today - 1 });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* POST /api/workspace/deal — Record a deal action
|
|
184
|
+
*/
|
|
185
|
+
router.post('/deal', authenticateToken, (req, res) => {
|
|
186
|
+
const { taskId, source, title, originalPrice, finalPrice, url, requiresLogin } = req.body;
|
|
187
|
+
const savings = originalPrice && finalPrice ? originalPrice - finalPrice : 0;
|
|
188
|
+
const id = crypto.randomUUID();
|
|
189
|
+
|
|
190
|
+
stmts.insertDeal.run(id, req.user.id, taskId || null, source || '', title || '', originalPrice || 0, finalPrice || 0, savings, url || '', requiresLogin ? 1 : 0);
|
|
191
|
+
logEvent(req.user.id, 'deal_created', { dealId: id, source, savings });
|
|
192
|
+
|
|
193
|
+
res.json({ dealId: id, savings });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* POST /api/workspace/deal/:id/status — Update deal status
|
|
198
|
+
*/
|
|
199
|
+
router.post('/deal/:id/status', authenticateToken, (req, res) => {
|
|
200
|
+
const { status } = req.body;
|
|
201
|
+
const valid = ['clicked', 'agent_executing', 'login_required', 'completed', 'failed'];
|
|
202
|
+
if (!valid.includes(status)) return res.status(400).json({ error: 'Invalid status' });
|
|
203
|
+
|
|
204
|
+
stmts.updateDealStatus.run(status, req.params.id);
|
|
205
|
+
|
|
206
|
+
if (status === 'completed') {
|
|
207
|
+
// Update savings in subscription
|
|
208
|
+
const deal = db.prepare('SELECT savings FROM workspace_deals WHERE id = ?').get(req.params.id);
|
|
209
|
+
if (deal) {
|
|
210
|
+
stmts.addDealSavings.run(deal.savings || 0, req.user.id);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
logEvent(req.user.id, 'deal_status', { dealId: req.params.id, status });
|
|
215
|
+
res.json({ success: true });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* GET /api/workspace/deals — Get user's deal history
|
|
220
|
+
*/
|
|
221
|
+
router.get('/deals', authenticateToken, (req, res) => {
|
|
222
|
+
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
|
|
223
|
+
const deals = stmts.getUserDeals.all(req.user.id, limit);
|
|
224
|
+
res.json({ deals });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* POST /api/workspace/event — Log workspace analytics event
|
|
229
|
+
*/
|
|
230
|
+
router.post('/event', authenticateToken, (req, res) => {
|
|
231
|
+
const { type, data } = req.body;
|
|
232
|
+
if (!type) return res.status(400).json({ error: 'Event type required' });
|
|
233
|
+
logEvent(req.user.id, type, data || {});
|
|
234
|
+
res.json({ ok: true });
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ─── Admin Routes ────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* GET /api/workspace/admin/stats — Global workspace stats
|
|
241
|
+
*/
|
|
242
|
+
router.get('/admin/stats', authenticateAdmin, (req, res) => {
|
|
243
|
+
const totalUsers = db.prepare('SELECT COUNT(*) as c FROM workspace_subscriptions').get()?.c || 0;
|
|
244
|
+
const activeToday = db.prepare("SELECT COUNT(*) as c FROM workspace_subscriptions WHERE last_task_date = date('now')").get()?.c || 0;
|
|
245
|
+
const totalTasks = db.prepare('SELECT SUM(tasks_total) as c FROM workspace_subscriptions').get()?.c || 0;
|
|
246
|
+
const totalDeals = db.prepare('SELECT SUM(deals_completed) as c FROM workspace_subscriptions').get()?.c || 0;
|
|
247
|
+
const totalSavings = db.prepare('SELECT SUM(total_savings) as c FROM workspace_subscriptions').get()?.c || 0;
|
|
248
|
+
|
|
249
|
+
const planBreakdown = db.prepare(`
|
|
250
|
+
SELECT plan, COUNT(*) as count, SUM(tasks_total) as tasks, SUM(total_savings) as savings
|
|
251
|
+
FROM workspace_subscriptions WHERE status = 'active' GROUP BY plan
|
|
252
|
+
`).all();
|
|
253
|
+
|
|
254
|
+
const recentEvents = db.prepare(`
|
|
255
|
+
SELECT event_type, COUNT(*) as count
|
|
256
|
+
FROM workspace_analytics
|
|
257
|
+
WHERE created_at > datetime('now', '-24 hours')
|
|
258
|
+
GROUP BY event_type ORDER BY count DESC LIMIT 10
|
|
259
|
+
`).all();
|
|
260
|
+
|
|
261
|
+
const dailyTasks = db.prepare(`
|
|
262
|
+
SELECT date(created_at) as day, COUNT(*) as count
|
|
263
|
+
FROM workspace_analytics
|
|
264
|
+
WHERE event_type IN ('task_started','deal_created') AND created_at > datetime('now', '-30 days')
|
|
265
|
+
GROUP BY day ORDER BY day
|
|
266
|
+
`).all();
|
|
267
|
+
|
|
268
|
+
res.json({
|
|
269
|
+
totalUsers,
|
|
270
|
+
activeToday,
|
|
271
|
+
totalTasks,
|
|
272
|
+
totalDeals,
|
|
273
|
+
totalSavings,
|
|
274
|
+
planBreakdown,
|
|
275
|
+
recentEvents,
|
|
276
|
+
dailyTasks,
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* GET /api/workspace/admin/subscriptions — List all subscriptions
|
|
282
|
+
*/
|
|
283
|
+
router.get('/admin/subscriptions', authenticateAdmin, (req, res) => {
|
|
284
|
+
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
285
|
+
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
|
|
286
|
+
const offset = (page - 1) * limit;
|
|
287
|
+
|
|
288
|
+
const subs = db.prepare(`
|
|
289
|
+
SELECT ws.*, u.email, u.name
|
|
290
|
+
FROM workspace_subscriptions ws
|
|
291
|
+
LEFT JOIN users u ON ws.user_id = u.id
|
|
292
|
+
ORDER BY ws.created_at DESC LIMIT ? OFFSET ?
|
|
293
|
+
`).all(limit, offset);
|
|
294
|
+
|
|
295
|
+
const total = db.prepare('SELECT COUNT(*) as c FROM workspace_subscriptions').get()?.c || 0;
|
|
296
|
+
|
|
297
|
+
res.json({ subscriptions: subs, total, page, pages: Math.ceil(total / limit) });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* PUT /api/workspace/admin/subscription/:userId — Admin update subscription
|
|
302
|
+
*/
|
|
303
|
+
router.put('/admin/subscription/:userId', authenticateAdmin, (req, res) => {
|
|
304
|
+
const { plan, status } = req.body;
|
|
305
|
+
if (plan && !PLAN_LIMITS[plan]) return res.status(400).json({ error: 'Invalid plan' });
|
|
306
|
+
|
|
307
|
+
if (plan) {
|
|
308
|
+
stmts.updateSubPlan.run(plan, status || 'active', req.params.userId, 'active');
|
|
309
|
+
}
|
|
310
|
+
if (status && !plan) {
|
|
311
|
+
db.prepare('UPDATE workspace_subscriptions SET status = ? WHERE user_id = ? AND status = ?')
|
|
312
|
+
.run(status, req.params.userId, 'active');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
res.json({ success: true });
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* GET /api/workspace/admin/deals — List all deals
|
|
320
|
+
*/
|
|
321
|
+
router.get('/admin/deals', authenticateAdmin, (req, res) => {
|
|
322
|
+
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
323
|
+
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
|
|
324
|
+
const offset = (page - 1) * limit;
|
|
325
|
+
|
|
326
|
+
const deals = db.prepare(`
|
|
327
|
+
SELECT wd.*, u.email, u.name as user_name
|
|
328
|
+
FROM workspace_deals wd
|
|
329
|
+
LEFT JOIN users u ON wd.user_id = u.id
|
|
330
|
+
ORDER BY wd.created_at DESC LIMIT ? OFFSET ?
|
|
331
|
+
`).all(limit, offset);
|
|
332
|
+
|
|
333
|
+
const total = db.prepare('SELECT COUNT(*) as c FROM workspace_deals').get()?.c || 0;
|
|
334
|
+
const totalSavings = db.prepare('SELECT SUM(savings) as s FROM workspace_deals WHERE status = ?').get('completed')?.s || 0;
|
|
335
|
+
|
|
336
|
+
res.json({ deals, total, totalSavings, page, pages: Math.ceil(total / limit) });
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* GET /api/workspace/admin/analytics — Workspace analytics dashboard
|
|
341
|
+
*/
|
|
342
|
+
router.get('/admin/analytics', authenticateAdmin, (req, res) => {
|
|
343
|
+
const days = Math.min(parseInt(req.query.days) || 30, 90);
|
|
344
|
+
|
|
345
|
+
const eventsByType = db.prepare(`
|
|
346
|
+
SELECT event_type, COUNT(*) as count
|
|
347
|
+
FROM workspace_analytics
|
|
348
|
+
WHERE created_at > datetime('now', '-${days} days')
|
|
349
|
+
GROUP BY event_type ORDER BY count DESC
|
|
350
|
+
`).all();
|
|
351
|
+
|
|
352
|
+
const dailyActivity = db.prepare(`
|
|
353
|
+
SELECT date(created_at) as day, COUNT(*) as events,
|
|
354
|
+
COUNT(DISTINCT user_id) as unique_users
|
|
355
|
+
FROM workspace_analytics
|
|
356
|
+
WHERE created_at > datetime('now', '-${days} days')
|
|
357
|
+
GROUP BY day ORDER BY day
|
|
358
|
+
`).all();
|
|
359
|
+
|
|
360
|
+
const topUsers = db.prepare(`
|
|
361
|
+
SELECT ws.user_id, u.email, u.name, ws.plan, ws.tasks_total, ws.deals_completed, ws.total_savings
|
|
362
|
+
FROM workspace_subscriptions ws
|
|
363
|
+
LEFT JOIN users u ON ws.user_id = u.id
|
|
364
|
+
ORDER BY ws.tasks_total DESC LIMIT 20
|
|
365
|
+
`).all();
|
|
366
|
+
|
|
367
|
+
res.json({ eventsByType, dailyActivity, topUsers });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
function logEvent(userId, type, data) {
|
|
373
|
+
try {
|
|
374
|
+
stmts.logEvent.run(crypto.randomUUID(), userId || null, type, JSON.stringify(data));
|
|
375
|
+
} catch (_) {}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
module.exports = router;
|
package/server/routes/api.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
2
|
const router = express.Router();
|
|
3
3
|
const { authenticateToken } = require('../middleware/auth');
|
|
4
|
+
const { apiLimiter } = require('../middleware/rateLimits');
|
|
5
|
+
const { validateDomain, validateSiteConfig, sanitizeInput, auditLog } = require('../services/security');
|
|
4
6
|
const {
|
|
5
7
|
addSite, findSitesByUser, findSiteById,
|
|
6
8
|
updateSiteConfig, updateSiteTier, deleteSite,
|
|
7
9
|
getAnalyticsBySite, getAnalyticsTimeline
|
|
8
10
|
} = require('../models/db');
|
|
9
11
|
|
|
12
|
+
// Apply general API rate limit to all routes
|
|
13
|
+
router.use(apiLimiter);
|
|
14
|
+
|
|
10
15
|
// ─── Sites ──────────────────────────────────────────────────────────────
|
|
11
16
|
|
|
12
17
|
router.get('/sites', authenticateToken, (req, res) => {
|
|
@@ -26,8 +31,16 @@ router.post('/sites', authenticateToken, (req, res) => {
|
|
|
26
31
|
return res.status(400).json({ error: 'Domain and name are required' });
|
|
27
32
|
}
|
|
28
33
|
|
|
34
|
+
if (!validateDomain(domain)) {
|
|
35
|
+
return res.status(400).json({ error: 'Invalid domain format. Must be a valid hostname (e.g., example.com)' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const cleanName = sanitizeInput(name, 200);
|
|
39
|
+
const cleanDesc = description ? sanitizeInput(description, 500) : undefined;
|
|
40
|
+
|
|
29
41
|
try {
|
|
30
|
-
const site = addSite({ userId: req.user.id, domain, name, description, tier });
|
|
42
|
+
const site = addSite({ userId: req.user.id, domain: domain.toLowerCase().trim(), name: cleanName, description: cleanDesc, tier });
|
|
43
|
+
auditLog({ actorType: 'user', actorId: String(req.user.id), action: 'site_created', resource: 'site', resourceId: String(site.id), ip: req.ip });
|
|
31
44
|
res.status(201).json({ site });
|
|
32
45
|
} catch (err) {
|
|
33
46
|
res.status(500).json({ error: 'Failed to create site' });
|
|
@@ -46,11 +59,17 @@ router.put('/sites/:id/config', authenticateToken, (req, res) => {
|
|
|
46
59
|
const { config } = req.body;
|
|
47
60
|
if (!config) return res.status(400).json({ error: 'Config is required' });
|
|
48
61
|
|
|
62
|
+
const validation = validateSiteConfig(config);
|
|
63
|
+
if (!validation.valid) {
|
|
64
|
+
return res.status(400).json({ error: validation.error });
|
|
65
|
+
}
|
|
66
|
+
|
|
49
67
|
try {
|
|
50
|
-
const r = updateSiteConfig.run(JSON.stringify(config), req.params.id, req.user.id);
|
|
68
|
+
const r = updateSiteConfig.run(JSON.stringify(validation.config), req.params.id, req.user.id);
|
|
51
69
|
if (r.changes === 0) {
|
|
52
70
|
return res.status(404).json({ error: 'Site not found' });
|
|
53
71
|
}
|
|
72
|
+
auditLog({ actorType: 'user', actorId: String(req.user.id), action: 'site_config_updated', resource: 'site', resourceId: req.params.id, ip: req.ip });
|
|
54
73
|
res.json({ success: true });
|
|
55
74
|
} catch (err) {
|
|
56
75
|
res.status(500).json({ error: 'Failed to update config' });
|
package/server/routes/auth.js
CHANGED
|
@@ -2,21 +2,31 @@ const express = require('express');
|
|
|
2
2
|
const router = express.Router();
|
|
3
3
|
const { registerUser, loginUser, findUserById } = require('../models/db');
|
|
4
4
|
const { generateToken, authenticateToken } = require('../middleware/auth');
|
|
5
|
+
const { authLimiter, registerLimiter } = require('../middleware/rateLimits');
|
|
6
|
+
const { validateEmail, sanitizeInput, auditLog, revokeJWT } = require('../services/security');
|
|
5
7
|
|
|
6
|
-
router.post('/register', (req, res) => {
|
|
8
|
+
router.post('/register', registerLimiter, (req, res) => {
|
|
7
9
|
const { email, password, name, company } = req.body;
|
|
8
10
|
|
|
9
11
|
if (!email || !password || !name) {
|
|
10
12
|
return res.status(400).json({ error: 'Email, password, and name are required' });
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
if (
|
|
14
|
-
return res.status(400).json({ error: '
|
|
15
|
+
if (!validateEmail(email)) {
|
|
16
|
+
return res.status(400).json({ error: 'Invalid email format' });
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
if (password.length < 8 || password.length > 128) {
|
|
20
|
+
return res.status(400).json({ error: 'Password must be between 8 and 128 characters' });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const cleanName = sanitizeInput(name, 100);
|
|
24
|
+
const cleanCompany = company ? sanitizeInput(company, 100) : undefined;
|
|
25
|
+
|
|
17
26
|
try {
|
|
18
|
-
const user = registerUser({ email, password, name, company });
|
|
27
|
+
const user = registerUser({ email: email.toLowerCase().trim(), password, name: cleanName, company: cleanCompany });
|
|
19
28
|
const token = generateToken(user);
|
|
29
|
+
auditLog({ actorType: 'user', actorId: String(user.id), action: 'register', ip: req.ip });
|
|
20
30
|
res.status(201).json({ user, token });
|
|
21
31
|
} catch (err) {
|
|
22
32
|
if (err.message.includes('UNIQUE constraint')) {
|
|
@@ -26,22 +36,32 @@ router.post('/register', (req, res) => {
|
|
|
26
36
|
}
|
|
27
37
|
});
|
|
28
38
|
|
|
29
|
-
router.post('/login', (req, res) => {
|
|
39
|
+
router.post('/login', authLimiter, (req, res) => {
|
|
30
40
|
const { email, password } = req.body;
|
|
31
41
|
|
|
32
42
|
if (!email || !password) {
|
|
33
43
|
return res.status(400).json({ error: 'Email and password are required' });
|
|
34
44
|
}
|
|
35
45
|
|
|
36
|
-
const user = loginUser({ email, password });
|
|
46
|
+
const user = loginUser({ email: email.toLowerCase().trim(), password });
|
|
37
47
|
if (!user) {
|
|
48
|
+
auditLog({ actorType: 'user', action: 'login_failed', details: { email: email.toLowerCase().trim() }, ip: req.ip, outcome: 'denied', severity: 'warning' });
|
|
38
49
|
return res.status(401).json({ error: 'Invalid email or password' });
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
const token = generateToken(user);
|
|
53
|
+
auditLog({ actorType: 'user', actorId: String(user.id), action: 'login', ip: req.ip });
|
|
42
54
|
res.json({ user, token });
|
|
43
55
|
});
|
|
44
56
|
|
|
57
|
+
router.post('/logout', authenticateToken, (req, res) => {
|
|
58
|
+
if (req._rawToken) {
|
|
59
|
+
revokeJWT(req._rawToken, 'user_logout');
|
|
60
|
+
auditLog({ actorType: 'user', actorId: String(req.user.id), action: 'logout', ip: req.ip });
|
|
61
|
+
}
|
|
62
|
+
res.json({ success: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
45
65
|
router.get('/me', authenticateToken, (req, res) => {
|
|
46
66
|
const user = findUserById.get(req.user.id);
|
|
47
67
|
if (!user) return res.status(404).json({ error: 'User not found' });
|