web-agent-bridge 3.9.2 → 3.10.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/bin/wab.js +54 -0
- package/package.json +1 -1
- package/public/forgot-password.html +68 -0
- package/public/login.html +3 -2
- package/public/reset-password.html +84 -0
- package/public/verify-email.html +76 -0
- package/server/middleware/auth.js +42 -1
- package/server/migrations/022_auth_recovery_verification.sql +27 -0
- package/server/migrations/023_atp_merchant_commission.sql +43 -0
- package/server/models/db.js +76 -1
- package/server/routes/admin.js +61 -0
- package/server/routes/auth.js +106 -3
- package/server/routes/premium.js +18 -18
- package/server/routes/transactions.js +32 -0
- package/server/services/commissions.js +209 -0
- package/server/services/email.js +53 -0
- package/server/services/stripe.js +108 -0
- package/server/services/transactions.js +15 -0
package/server/routes/auth.js
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
|
+
const crypto = require('crypto');
|
|
2
3
|
const router = express.Router();
|
|
3
|
-
const {
|
|
4
|
+
const {
|
|
5
|
+
registerUser, loginUser, findUserById, findUserByEmail,
|
|
6
|
+
createPasswordResetToken, consumePasswordResetToken, updateUserPassword,
|
|
7
|
+
createEmailVerificationToken, consumeEmailVerificationToken, isEmailVerified
|
|
8
|
+
} = require('../models/db');
|
|
4
9
|
const { generateToken, authenticateToken } = require('../middleware/auth');
|
|
5
10
|
const { authLimiter, registerLimiter } = require('../middleware/rateLimits');
|
|
6
11
|
const { validateEmail, sanitizeInput, auditLog, revokeJWT } = require('../services/security');
|
|
12
|
+
const { sendEmail } = require('../services/email');
|
|
13
|
+
|
|
14
|
+
const BASE_URL = process.env.BASE_URL || 'https://webagentbridge.com';
|
|
15
|
+
|
|
16
|
+
function hashToken(token) {
|
|
17
|
+
return crypto.createHash('sha256').update(token).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fireAndForget(promise, label) {
|
|
21
|
+
Promise.resolve(promise).catch((e) => {
|
|
22
|
+
console.error(`[email] ${label} failed (non-fatal):`, e && e.message);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
7
25
|
|
|
8
26
|
router.post('/register', registerLimiter, (req, res) => {
|
|
9
27
|
const { email, password, name, company } = req.body;
|
|
@@ -27,7 +45,25 @@ router.post('/register', registerLimiter, (req, res) => {
|
|
|
27
45
|
const user = registerUser({ email: email.toLowerCase().trim(), password, name: cleanName, company: cleanCompany });
|
|
28
46
|
const token = generateToken(user);
|
|
29
47
|
auditLog({ actorType: 'user', actorId: String(user.id), action: 'register', ip: req.ip });
|
|
30
|
-
|
|
48
|
+
|
|
49
|
+
// Generate email-verification token and send it (best-effort, non-blocking)
|
|
50
|
+
try {
|
|
51
|
+
const verifyToken = crypto.randomBytes(32).toString('hex');
|
|
52
|
+
createEmailVerificationToken({ userId: user.id, tokenHash: hashToken(verifyToken) });
|
|
53
|
+
const verifyUrl = `${BASE_URL}/verify-email.html?token=${verifyToken}`;
|
|
54
|
+
fireAndForget(
|
|
55
|
+
sendEmail({ to: user.email, template: 'email_verification', data: { name: user.name, verifyUrl }, userId: user.id }),
|
|
56
|
+
'verification email'
|
|
57
|
+
);
|
|
58
|
+
fireAndForget(
|
|
59
|
+
sendEmail({ to: user.email, template: 'welcome', data: { name: user.name, dashboardUrl: `${BASE_URL}/dashboard` }, userId: user.id }),
|
|
60
|
+
'welcome email'
|
|
61
|
+
);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error('[register] verification setup failed:', e.message);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
res.status(201).json({ user: { ...user, email_verified: 0 }, token });
|
|
31
67
|
} catch (err) {
|
|
32
68
|
if (err.message.includes('UNIQUE constraint')) {
|
|
33
69
|
return res.status(409).json({ error: 'Email already registered' });
|
|
@@ -65,7 +101,74 @@ router.post('/logout', authenticateToken, (req, res) => {
|
|
|
65
101
|
router.get('/me', authenticateToken, (req, res) => {
|
|
66
102
|
const user = findUserById.get(req.user.id);
|
|
67
103
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
68
|
-
res.json({ user });
|
|
104
|
+
res.json({ user: { ...user, email_verified: isEmailVerified(req.user.id) ? 1 : 0 } });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ─── Password Reset ────────────────────────────────────────────────────
|
|
108
|
+
// Always returns success to avoid leaking which emails exist.
|
|
109
|
+
router.post('/forgot-password', authLimiter, (req, res) => {
|
|
110
|
+
const { email } = req.body || {};
|
|
111
|
+
if (!email || !validateEmail(email)) {
|
|
112
|
+
return res.status(400).json({ error: 'Valid email required' });
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const user = findUserByEmail.get(email.toLowerCase().trim());
|
|
116
|
+
if (user) {
|
|
117
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
118
|
+
createPasswordResetToken({ userId: user.id, tokenHash: hashToken(token), ttlMinutes: 60 });
|
|
119
|
+
const resetUrl = `${BASE_URL}/reset-password.html?token=${token}`;
|
|
120
|
+
fireAndForget(
|
|
121
|
+
sendEmail({ to: user.email, template: 'password_reset', data: { name: user.name, resetUrl }, userId: user.id }),
|
|
122
|
+
'password_reset email'
|
|
123
|
+
);
|
|
124
|
+
auditLog({ actorType: 'user', actorId: String(user.id), action: 'password_reset_requested', ip: req.ip });
|
|
125
|
+
}
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.error('[forgot-password]', e.message);
|
|
128
|
+
}
|
|
129
|
+
res.json({ success: true, message: 'If that email is registered, a reset link has been sent.' });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
router.post('/reset-password', authLimiter, (req, res) => {
|
|
133
|
+
const { token, password } = req.body || {};
|
|
134
|
+
if (!token || typeof token !== 'string') return res.status(400).json({ error: 'Token required' });
|
|
135
|
+
if (!password || password.length < 8 || password.length > 128) {
|
|
136
|
+
return res.status(400).json({ error: 'Password must be between 8 and 128 characters' });
|
|
137
|
+
}
|
|
138
|
+
const userId = consumePasswordResetToken(hashToken(token));
|
|
139
|
+
if (!userId) {
|
|
140
|
+
return res.status(400).json({ error: 'Invalid or expired token' });
|
|
141
|
+
}
|
|
142
|
+
updateUserPassword(userId, password);
|
|
143
|
+
auditLog({ actorType: 'user', actorId: String(userId), action: 'password_reset_completed', ip: req.ip });
|
|
144
|
+
res.json({ success: true });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ─── Email Verification ────────────────────────────────────────────────
|
|
148
|
+
router.post('/verify-email', (req, res) => {
|
|
149
|
+
const { token } = req.body || {};
|
|
150
|
+
if (!token || typeof token !== 'string') return res.status(400).json({ error: 'Token required' });
|
|
151
|
+
const userId = consumeEmailVerificationToken(hashToken(token));
|
|
152
|
+
if (!userId) return res.status(400).json({ error: 'Invalid or expired token' });
|
|
153
|
+
auditLog({ actorType: 'user', actorId: String(userId), action: 'email_verified', ip: req.ip });
|
|
154
|
+
res.json({ success: true });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
router.post('/resend-verification', authenticateToken, (req, res) => {
|
|
158
|
+
const user = findUserById.get(req.user.id);
|
|
159
|
+
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
160
|
+
if (isEmailVerified(req.user.id)) {
|
|
161
|
+
return res.json({ success: true, alreadyVerified: true });
|
|
162
|
+
}
|
|
163
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
164
|
+
createEmailVerificationToken({ userId: user.id, tokenHash: hashToken(token) });
|
|
165
|
+
const verifyUrl = `${BASE_URL}/verify-email.html?token=${token}`;
|
|
166
|
+
fireAndForget(
|
|
167
|
+
sendEmail({ to: user.email, template: 'email_verification', data: { name: user.name, verifyUrl }, userId: user.id }),
|
|
168
|
+
'resend verification'
|
|
169
|
+
);
|
|
170
|
+
auditLog({ actorType: 'user', actorId: String(req.user.id), action: 'verification_resent', ip: req.ip });
|
|
171
|
+
res.json({ success: true });
|
|
69
172
|
});
|
|
70
173
|
|
|
71
174
|
module.exports = router;
|
package/server/routes/premium.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
2
|
const router = express.Router();
|
|
3
|
-
const { authenticateToken } = require('../middleware/auth');
|
|
3
|
+
const { authenticateToken, requireTier } = require('../middleware/auth');
|
|
4
4
|
const premium = require('../services/premium');
|
|
5
5
|
const { findSiteById, findSitesByUser } = require('../models/db');
|
|
6
6
|
|
|
@@ -15,7 +15,7 @@ function requireSiteOwnership(req, res, next) {
|
|
|
15
15
|
|
|
16
16
|
// ─── Traffic Intelligence ────────────────────────────────────────────────
|
|
17
17
|
|
|
18
|
-
router.get('/traffic/:siteId/profiles', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
18
|
+
router.get('/traffic/:siteId/profiles', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
|
|
19
19
|
try {
|
|
20
20
|
const { limit, offset, type } = req.query;
|
|
21
21
|
const profiles = await premium.getAgentProfiles(req.params.siteId, {
|
|
@@ -29,7 +29,7 @@ router.get('/traffic/:siteId/profiles', authenticateToken, requireSiteOwnership,
|
|
|
29
29
|
}
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
router.get('/traffic/:siteId/stats', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
32
|
+
router.get('/traffic/:siteId/stats', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
|
|
33
33
|
try {
|
|
34
34
|
const days = req.query.days ? parseInt(req.query.days) : 30;
|
|
35
35
|
const stats = await premium.getTrafficStats(req.params.siteId, days);
|
|
@@ -39,7 +39,7 @@ router.get('/traffic/:siteId/stats', authenticateToken, requireSiteOwnership, as
|
|
|
39
39
|
}
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
router.get('/traffic/:siteId/alerts', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
42
|
+
router.get('/traffic/:siteId/alerts', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
|
|
43
43
|
try {
|
|
44
44
|
const { limit, acknowledged } = req.query;
|
|
45
45
|
const alerts = await premium.getAnomalyAlerts(req.params.siteId, {
|
|
@@ -52,7 +52,7 @@ router.get('/traffic/:siteId/alerts', authenticateToken, requireSiteOwnership, a
|
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
router.post('/traffic/:siteId/alerts/:alertId/acknowledge', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
55
|
+
router.post('/traffic/:siteId/alerts/:alertId/acknowledge', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
|
|
56
56
|
try {
|
|
57
57
|
const ok = await premium.acknowledgeAlert(req.params.alertId, req.params.siteId);
|
|
58
58
|
if (!ok) return res.status(404).json({ error: 'Alert not found' });
|
|
@@ -62,7 +62,7 @@ router.post('/traffic/:siteId/alerts/:alertId/acknowledge', authenticateToken, r
|
|
|
62
62
|
}
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
router.post('/traffic/:siteId/check-anomalies', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
65
|
+
router.post('/traffic/:siteId/check-anomalies', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
|
|
66
66
|
try {
|
|
67
67
|
const alerts = await premium.checkForAnomalies(req.params.siteId);
|
|
68
68
|
res.json({ alerts });
|
|
@@ -73,7 +73,7 @@ router.post('/traffic/:siteId/check-anomalies', authenticateToken, requireSiteOw
|
|
|
73
73
|
|
|
74
74
|
// ─── Exploit Shield ──────────────────────────────────────────────────────
|
|
75
75
|
|
|
76
|
-
router.get('/security/:siteId/events', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
76
|
+
router.get('/security/:siteId/events', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
|
|
77
77
|
try {
|
|
78
78
|
const { limit, severity, since } = req.query;
|
|
79
79
|
const events = await premium.getSecurityEvents(req.params.siteId, {
|
|
@@ -87,7 +87,7 @@ router.get('/security/:siteId/events', authenticateToken, requireSiteOwnership,
|
|
|
87
87
|
}
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
router.get('/security/:siteId/report', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
90
|
+
router.get('/security/:siteId/report', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
|
|
91
91
|
try {
|
|
92
92
|
const days = req.query.days ? parseInt(req.query.days) : 30;
|
|
93
93
|
const report = await premium.getSecurityReport(req.params.siteId, days);
|
|
@@ -97,7 +97,7 @@ router.get('/security/:siteId/report', authenticateToken, requireSiteOwnership,
|
|
|
97
97
|
}
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
router.get('/security/:siteId/blocked', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
100
|
+
router.get('/security/:siteId/blocked', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
|
|
101
101
|
try {
|
|
102
102
|
const blocked = await premium.getBlockedAgents(req.params.siteId);
|
|
103
103
|
res.json({ blocked });
|
|
@@ -106,7 +106,7 @@ router.get('/security/:siteId/blocked', authenticateToken, requireSiteOwnership,
|
|
|
106
106
|
}
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
router.post('/security/:siteId/block', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
109
|
+
router.post('/security/:siteId/block', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
|
|
110
110
|
try {
|
|
111
111
|
const { agentSignature, reason, expiresAt } = req.body;
|
|
112
112
|
if (!agentSignature) return res.status(400).json({ error: 'agentSignature is required' });
|
|
@@ -117,7 +117,7 @@ router.post('/security/:siteId/block', authenticateToken, requireSiteOwnership,
|
|
|
117
117
|
}
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
-
router.delete('/security/:siteId/block/:blockId', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
120
|
+
router.delete('/security/:siteId/block/:blockId', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
|
|
121
121
|
try {
|
|
122
122
|
const ok = await premium.unblockAgent(req.params.blockId, req.params.siteId);
|
|
123
123
|
if (!ok) return res.status(404).json({ error: 'Block record not found' });
|
|
@@ -193,7 +193,7 @@ router.delete('/actions/:siteId/install/:installId', authenticateToken, requireS
|
|
|
193
193
|
|
|
194
194
|
// ─── Custom Agents ───────────────────────────────────────────────────────
|
|
195
195
|
|
|
196
|
-
router.get('/agents/:siteId', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
196
|
+
router.get('/agents/:siteId', authenticateToken, requireSiteOwnership, requireTier('pro'), async (req, res) => {
|
|
197
197
|
try {
|
|
198
198
|
const agents = await premium.getAgents(req.user.id, req.params.siteId);
|
|
199
199
|
res.json({ agents });
|
|
@@ -202,7 +202,7 @@ router.get('/agents/:siteId', authenticateToken, requireSiteOwnership, async (re
|
|
|
202
202
|
}
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
-
router.post('/agents/:siteId', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
205
|
+
router.post('/agents/:siteId', authenticateToken, requireSiteOwnership, requireTier('pro'), async (req, res) => {
|
|
206
206
|
try {
|
|
207
207
|
const { name, description, steps, schedule } = req.body;
|
|
208
208
|
if (!name || !steps) return res.status(400).json({ error: 'name and steps are required' });
|
|
@@ -213,7 +213,7 @@ router.post('/agents/:siteId', authenticateToken, requireSiteOwnership, async (r
|
|
|
213
213
|
}
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
-
router.get('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
216
|
+
router.get('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership, requireTier('pro'), async (req, res) => {
|
|
217
217
|
try {
|
|
218
218
|
const agent = await premium.getAgent(req.params.agentId, req.user.id);
|
|
219
219
|
if (!agent) return res.status(404).json({ error: 'Agent not found' });
|
|
@@ -223,7 +223,7 @@ router.get('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership,
|
|
|
223
223
|
}
|
|
224
224
|
});
|
|
225
225
|
|
|
226
|
-
router.put('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
226
|
+
router.put('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership, requireTier('pro'), async (req, res) => {
|
|
227
227
|
try {
|
|
228
228
|
const { name, description, steps, schedule } = req.body;
|
|
229
229
|
const ok = await premium.updateAgent(req.params.agentId, req.user.id, { name, description, steps, schedule });
|
|
@@ -234,7 +234,7 @@ router.put('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership,
|
|
|
234
234
|
}
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
-
router.delete('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
237
|
+
router.delete('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership, requireTier('pro'), async (req, res) => {
|
|
238
238
|
try {
|
|
239
239
|
const ok = await premium.deleteAgent(req.params.agentId, req.user.id);
|
|
240
240
|
if (!ok) return res.status(404).json({ error: 'Agent not found' });
|
|
@@ -244,7 +244,7 @@ router.delete('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnershi
|
|
|
244
244
|
}
|
|
245
245
|
});
|
|
246
246
|
|
|
247
|
-
router.post('/agents/:siteId/:agentId/run', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
247
|
+
router.post('/agents/:siteId/:agentId/run', authenticateToken, requireSiteOwnership, requireTier('pro'), async (req, res) => {
|
|
248
248
|
try {
|
|
249
249
|
const result = await premium.runAgent(req.params.agentId, req.user.id);
|
|
250
250
|
res.json({ result });
|
|
@@ -253,7 +253,7 @@ router.post('/agents/:siteId/:agentId/run', authenticateToken, requireSiteOwners
|
|
|
253
253
|
}
|
|
254
254
|
});
|
|
255
255
|
|
|
256
|
-
router.get('/agents/:siteId/:agentId/runs', authenticateToken, requireSiteOwnership, async (req, res) => {
|
|
256
|
+
router.get('/agents/:siteId/:agentId/runs', authenticateToken, requireSiteOwnership, requireTier('pro'), async (req, res) => {
|
|
257
257
|
try {
|
|
258
258
|
const { limit } = req.query;
|
|
259
259
|
const runs = await premium.getAgentRuns(req.params.agentId, {
|
|
@@ -245,4 +245,36 @@ router.get('/platform/stats', publicReceiptLimiter, (req, res) => {
|
|
|
245
245
|
res.json({ ok: true, data: stats });
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
+
// ─── Merchant commission endpoints ────────────────────────────────────────
|
|
249
|
+
// Every merchant tx settled through ATP on a paid plan accrues a small
|
|
250
|
+
// platform commission (default 0.10%). Merchants can inspect their own
|
|
251
|
+
// accrual via these endpoints. Public commission rate is exposed for
|
|
252
|
+
// transparency.
|
|
253
|
+
const commissions = require('../services/commissions');
|
|
254
|
+
|
|
255
|
+
router.get('/commissions/rate', (req, res) => {
|
|
256
|
+
res.json({
|
|
257
|
+
ok: true,
|
|
258
|
+
data: {
|
|
259
|
+
rate_bps: commissions.getCommissionBps(),
|
|
260
|
+
rate_percent: commissions.getCommissionBps() / 100,
|
|
261
|
+
min_tier: commissions.getMinTier(),
|
|
262
|
+
applies_to: 'Successful merchant transactions settled through ATP on a paid plan.',
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
router.get('/commissions', authenticateToken, (req, res) => {
|
|
268
|
+
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
|
|
269
|
+
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
|
|
270
|
+
const status = req.query.status || null;
|
|
271
|
+
const items = commissions.listCommissionsForMerchant(req.user.id, { limit, offset, status });
|
|
272
|
+
res.json({ ok: true, data: items, limit, offset });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
router.get('/commissions/stats', authenticateToken, (req, res) => {
|
|
276
|
+
const stats = commissions.getMerchantCommissionStats(req.user.id);
|
|
277
|
+
res.json({ ok: true, data: stats });
|
|
278
|
+
});
|
|
279
|
+
|
|
248
280
|
module.exports = router;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ATP Merchant Commission — v3.10.0
|
|
5
|
+
*
|
|
6
|
+
* WAB charges a small platform commission on every successful merchant
|
|
7
|
+
* transaction settled through ATP, when the merchant is on a paid plan
|
|
8
|
+
* and the transaction is real (not a platform self-payment).
|
|
9
|
+
*
|
|
10
|
+
* Defaults:
|
|
11
|
+
* - Rate: 10 bps (0.10%), overridable via env WAB_COMMISSION_BPS or
|
|
12
|
+
* platform_settings('commission_bps').
|
|
13
|
+
* - Minimum tier: 'starter' (free sites exempt), overridable via env
|
|
14
|
+
* WAB_COMMISSION_MIN_TIER.
|
|
15
|
+
* - Platform self-payments (intent.metadata.platform = 1) always exempt.
|
|
16
|
+
*
|
|
17
|
+
* Idempotency: atp_commissions.transaction_id is UNIQUE, so duplicate
|
|
18
|
+
* settle events become a no-op.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
const { db, getPlatformSetting, findSiteById } = require('../models/db');
|
|
23
|
+
|
|
24
|
+
const TIER_RANK = { free: 0, starter: 1, pro: 2, business: 3, enterprise: 4 };
|
|
25
|
+
|
|
26
|
+
function ulid(prefix) {
|
|
27
|
+
const t = Date.now().toString(36).padStart(8, '0');
|
|
28
|
+
const r = crypto.randomBytes(10).toString('hex');
|
|
29
|
+
return `${prefix}_${t}${r}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getCommissionBps() {
|
|
33
|
+
// Priority: env > platform_setting > default
|
|
34
|
+
const envBps = parseInt(process.env.WAB_COMMISSION_BPS, 10);
|
|
35
|
+
if (Number.isFinite(envBps) && envBps >= 0 && envBps <= 10000) return envBps;
|
|
36
|
+
try {
|
|
37
|
+
const setting = getPlatformSetting('commission_bps');
|
|
38
|
+
if (setting != null) {
|
|
39
|
+
const n = parseInt(setting, 10);
|
|
40
|
+
if (Number.isFinite(n) && n >= 0 && n <= 10000) return n;
|
|
41
|
+
}
|
|
42
|
+
} catch { /* table may not exist in some tests */ }
|
|
43
|
+
return 10; // default 0.10%
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getMinTier() {
|
|
47
|
+
const env = (process.env.WAB_COMMISSION_MIN_TIER || '').toLowerCase().trim();
|
|
48
|
+
if (env && TIER_RANK[env] != null) return env;
|
|
49
|
+
return 'starter';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function calcCommissionCents(amountCents, bps) {
|
|
53
|
+
if (!Number.isFinite(amountCents) || amountCents <= 0) return 0;
|
|
54
|
+
// round half-up to nearest cent
|
|
55
|
+
return Math.floor((amountCents * bps + 5000) / 10000);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function safeJson(s, fallback) {
|
|
59
|
+
if (s == null) return fallback;
|
|
60
|
+
if (typeof s === 'object') return s;
|
|
61
|
+
try { return JSON.parse(s); } catch { return fallback; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Record a commission row for a settled merchant transaction.
|
|
66
|
+
* Idempotent: safe to call multiple times for the same tx.
|
|
67
|
+
* Returns the commission row (or null when not applicable).
|
|
68
|
+
*/
|
|
69
|
+
function recordCommissionForTransaction(tx) {
|
|
70
|
+
if (!tx || !tx.id || !tx.intent_id) return null;
|
|
71
|
+
if (!Number.isFinite(tx.amount_cents) || tx.amount_cents <= 0) return null;
|
|
72
|
+
|
|
73
|
+
// Already recorded?
|
|
74
|
+
const existing = db.prepare('SELECT * FROM atp_commissions WHERE transaction_id=?').get(tx.id);
|
|
75
|
+
if (existing) return existing;
|
|
76
|
+
|
|
77
|
+
const intent = db.prepare('SELECT user_id, site_id, metadata FROM atp_intents WHERE id=?').get(tx.intent_id);
|
|
78
|
+
if (!intent) return null;
|
|
79
|
+
|
|
80
|
+
// Platform self-payment? Skip.
|
|
81
|
+
const meta = safeJson(intent.metadata, {});
|
|
82
|
+
if (meta && meta.platform) return null;
|
|
83
|
+
|
|
84
|
+
// Need a merchant site to bill against.
|
|
85
|
+
if (!intent.site_id) return null;
|
|
86
|
+
|
|
87
|
+
let site;
|
|
88
|
+
try { site = findSiteById.get(intent.site_id); } catch { site = null; }
|
|
89
|
+
if (!site) return null;
|
|
90
|
+
|
|
91
|
+
const tier = (site.tier || 'free').toLowerCase();
|
|
92
|
+
const minTier = getMinTier();
|
|
93
|
+
if ((TIER_RANK[tier] ?? 0) < (TIER_RANK[minTier] ?? 1)) return null;
|
|
94
|
+
|
|
95
|
+
const bps = getCommissionBps();
|
|
96
|
+
if (bps <= 0) return null;
|
|
97
|
+
|
|
98
|
+
const commissionCents = calcCommissionCents(tx.amount_cents, bps);
|
|
99
|
+
if (commissionCents <= 0) return null;
|
|
100
|
+
|
|
101
|
+
const id = ulid('atp_com');
|
|
102
|
+
const externalRef =
|
|
103
|
+
(tx.metadata && safeJson(tx.metadata, {}).external_ref) ||
|
|
104
|
+
tx.idempotency_key || null;
|
|
105
|
+
|
|
106
|
+
db.prepare(`
|
|
107
|
+
INSERT INTO atp_commissions
|
|
108
|
+
(id, transaction_id, intent_id, merchant_user_id, merchant_site_id,
|
|
109
|
+
merchant_tier, gross_amount_cents, currency, commission_bps, commission_cents,
|
|
110
|
+
status, external_ref)
|
|
111
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)
|
|
112
|
+
`).run(
|
|
113
|
+
id, tx.id, tx.intent_id, site.user_id, site.id,
|
|
114
|
+
tier, tx.amount_cents, tx.currency || 'EUR', bps, commissionCents,
|
|
115
|
+
externalRef
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return db.prepare('SELECT * FROM atp_commissions WHERE id=?').get(id);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Flip a commission to 'refunded' when the underlying tx is compensated.
|
|
123
|
+
*/
|
|
124
|
+
function markCommissionRefunded(txId, reason = null) {
|
|
125
|
+
const row = db.prepare('SELECT * FROM atp_commissions WHERE transaction_id=?').get(txId);
|
|
126
|
+
if (!row) return null;
|
|
127
|
+
if (row.status === 'refunded' || row.status === 'waived') return row;
|
|
128
|
+
db.prepare(`
|
|
129
|
+
UPDATE atp_commissions
|
|
130
|
+
SET status='refunded',
|
|
131
|
+
notes = COALESCE(notes || ' | ', '') || ?,
|
|
132
|
+
updated_at = datetime('now')
|
|
133
|
+
WHERE transaction_id=?
|
|
134
|
+
`).run(`refund: ${reason || 'tx_compensated'}`, txId);
|
|
135
|
+
return db.prepare('SELECT * FROM atp_commissions WHERE transaction_id=?').get(txId);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function listCommissionsForMerchant(userId, { limit = 50, offset = 0, status = null } = {}) {
|
|
139
|
+
const lim = Math.max(1, Math.min(200, Number(limit) || 50));
|
|
140
|
+
const off = Math.max(0, Number(offset) || 0);
|
|
141
|
+
if (status) {
|
|
142
|
+
return db.prepare(`
|
|
143
|
+
SELECT * FROM atp_commissions
|
|
144
|
+
WHERE merchant_user_id=? AND status=?
|
|
145
|
+
ORDER BY created_at DESC LIMIT ? OFFSET ?
|
|
146
|
+
`).all(userId, status, lim, off);
|
|
147
|
+
}
|
|
148
|
+
return db.prepare(`
|
|
149
|
+
SELECT * FROM atp_commissions
|
|
150
|
+
WHERE merchant_user_id=?
|
|
151
|
+
ORDER BY created_at DESC LIMIT ? OFFSET ?
|
|
152
|
+
`).all(userId, lim, off);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getMerchantCommissionStats(userId) {
|
|
156
|
+
const overall = db.prepare(`
|
|
157
|
+
SELECT COUNT(*) AS count_total,
|
|
158
|
+
COALESCE(SUM(commission_cents), 0) AS commission_total_cents,
|
|
159
|
+
COALESCE(SUM(gross_amount_cents), 0) AS gross_total_cents
|
|
160
|
+
FROM atp_commissions
|
|
161
|
+
WHERE merchant_user_id = ?
|
|
162
|
+
AND status IN ('pending','invoiced','collected')
|
|
163
|
+
`).get(userId);
|
|
164
|
+
const byStatus = db.prepare(`
|
|
165
|
+
SELECT status,
|
|
166
|
+
COUNT(*) AS n,
|
|
167
|
+
COALESCE(SUM(commission_cents), 0) AS commission_cents
|
|
168
|
+
FROM atp_commissions
|
|
169
|
+
WHERE merchant_user_id = ?
|
|
170
|
+
GROUP BY status
|
|
171
|
+
`).all(userId);
|
|
172
|
+
return { ...overall, by_status: byStatus, rate_bps: getCommissionBps() };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function getPlatformCommissionStats() {
|
|
176
|
+
const row = db.prepare(`
|
|
177
|
+
SELECT COUNT(*) AS count_total,
|
|
178
|
+
COALESCE(SUM(commission_cents), 0) AS commission_total_cents,
|
|
179
|
+
COALESCE(SUM(gross_amount_cents), 0) AS gross_total_cents,
|
|
180
|
+
MIN(created_at) AS first_at,
|
|
181
|
+
MAX(created_at) AS last_at
|
|
182
|
+
FROM atp_commissions
|
|
183
|
+
WHERE status IN ('pending','invoiced','collected')
|
|
184
|
+
`).get();
|
|
185
|
+
const byStatus = db.prepare(`
|
|
186
|
+
SELECT status, COUNT(*) AS n, COALESCE(SUM(commission_cents),0) AS commission_cents
|
|
187
|
+
FROM atp_commissions GROUP BY status
|
|
188
|
+
`).all();
|
|
189
|
+
const byTier = db.prepare(`
|
|
190
|
+
SELECT merchant_tier AS tier, COUNT(*) AS n,
|
|
191
|
+
COALESCE(SUM(commission_cents),0) AS commission_cents
|
|
192
|
+
FROM atp_commissions
|
|
193
|
+
WHERE status IN ('pending','invoiced','collected')
|
|
194
|
+
GROUP BY merchant_tier
|
|
195
|
+
ORDER BY commission_cents DESC
|
|
196
|
+
`).all();
|
|
197
|
+
return { ...row, by_status: byStatus, by_tier: byTier, rate_bps: getCommissionBps() };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
recordCommissionForTransaction,
|
|
202
|
+
markCommissionRefunded,
|
|
203
|
+
listCommissionsForMerchant,
|
|
204
|
+
getMerchantCommissionStats,
|
|
205
|
+
getPlatformCommissionStats,
|
|
206
|
+
getCommissionBps,
|
|
207
|
+
getMinTier,
|
|
208
|
+
_calcCommissionCents: calcCommissionCents,
|
|
209
|
+
};
|
package/server/services/email.js
CHANGED
|
@@ -123,6 +123,59 @@ const templates = {
|
|
|
123
123
|
`
|
|
124
124
|
}),
|
|
125
125
|
|
|
126
|
+
email_verification: (data) => ({
|
|
127
|
+
subject: 'Verify your email — Web Agent Bridge',
|
|
128
|
+
html: `
|
|
129
|
+
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;background:#0a0e1a;color:#f0f4ff;padding:40px;border-radius:12px;">
|
|
130
|
+
<div style="text-align:center;margin-bottom:30px;">
|
|
131
|
+
<div style="font-size:40px;">✉️</div>
|
|
132
|
+
<h1 style="color:#3b82f6;margin:10px 0;">Verify your email</h1>
|
|
133
|
+
</div>
|
|
134
|
+
<p style="color:#94a3b8;">Hello ${escapeHtml(data.name)},</p>
|
|
135
|
+
<p style="color:#94a3b8;line-height:1.8;">
|
|
136
|
+
Thanks for signing up for Web Agent Bridge. Please confirm your email by clicking the button below.
|
|
137
|
+
</p>
|
|
138
|
+
<div style="text-align:center;margin:30px 0;">
|
|
139
|
+
<a href="${data.verifyUrl}" style="background:linear-gradient(135deg,#3b82f6,#8b5cf6);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;">Verify Email</a>
|
|
140
|
+
</div>
|
|
141
|
+
<p style="color:#64748b;font-size:13px;">
|
|
142
|
+
This link expires in 7 days. If you didn't create an account, ignore this email.
|
|
143
|
+
</p>
|
|
144
|
+
<p style="color:#64748b;font-size:12px;text-align:center;margin-top:30px;">
|
|
145
|
+
© ${new Date().getFullYear()} Web Agent Bridge
|
|
146
|
+
</p>
|
|
147
|
+
</div>
|
|
148
|
+
`
|
|
149
|
+
}),
|
|
150
|
+
|
|
151
|
+
license_delivery: (data) => ({
|
|
152
|
+
subject: `Your ${sanitizeSubjectPart(data.tier || 'WAB')} license is active — Web Agent Bridge`,
|
|
153
|
+
html: `
|
|
154
|
+
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;background:#0a0e1a;color:#f0f4ff;padding:40px;border-radius:12px;">
|
|
155
|
+
<div style="text-align:center;margin-bottom:30px;">
|
|
156
|
+
<div style="font-size:40px;">🎟️</div>
|
|
157
|
+
<h1 style="color:#10b981;margin:10px 0;">Payment received</h1>
|
|
158
|
+
</div>
|
|
159
|
+
<p style="color:#94a3b8;">Hello ${escapeHtml(data.name || '')},</p>
|
|
160
|
+
<p style="color:#94a3b8;line-height:1.8;">
|
|
161
|
+
Your subscription to <strong style="color:#10b981;">${escapeHtml(String(data.tier || '').toUpperCase())}</strong> is now active.
|
|
162
|
+
Below are your license details — keep them safe.
|
|
163
|
+
</p>
|
|
164
|
+
<div style="background:#1a2236;border-radius:8px;padding:20px;margin:20px 0;">
|
|
165
|
+
${data.siteDomain ? `<p style="color:#94a3b8;"><strong style="color:#f0f4ff;">Site:</strong> ${escapeHtml(data.siteDomain)}</p>` : ''}
|
|
166
|
+
${data.licenseKey ? `<p style="color:#94a3b8;"><strong style="color:#f0f4ff;">License key:</strong> <code style="color:#f0f4ff;">${escapeHtml(data.licenseKey)}</code></p>` : ''}
|
|
167
|
+
${data.amount ? `<p style="color:#94a3b8;"><strong style="color:#f0f4ff;">Amount:</strong> ${escapeHtml(data.amount)}</p>` : ''}
|
|
168
|
+
</div>
|
|
169
|
+
<div style="text-align:center;margin-top:30px;">
|
|
170
|
+
<a href="${data.dashboardUrl || 'https://webagentbridge.com/dashboard'}" style="background:linear-gradient(135deg,#10b981,#3b82f6);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;">Open Dashboard</a>
|
|
171
|
+
</div>
|
|
172
|
+
<p style="color:#64748b;font-size:12px;text-align:center;margin-top:30px;">
|
|
173
|
+
© ${new Date().getFullYear()} Web Agent Bridge
|
|
174
|
+
</p>
|
|
175
|
+
</div>
|
|
176
|
+
`
|
|
177
|
+
}),
|
|
178
|
+
|
|
126
179
|
contact: (data) => ({
|
|
127
180
|
subject: `New Contact Message: ${sanitizeSubjectPart(data.subject || 'No Subject')}`,
|
|
128
181
|
html: `
|