web-agent-bridge 3.9.2 → 3.10.1
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/index.js +6 -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 +79 -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/commission-billing.js +279 -0
- package/server/services/commissions.js +209 -0
- package/server/services/email.js +53 -0
- package/server/services/stripe.js +120 -0
- package/server/services/transactions.js +15 -0
package/bin/wab.js
CHANGED
|
@@ -64,6 +64,60 @@ switch (command) {
|
|
|
64
64
|
fs.writeFileSync(envTarget, defaultEnv);
|
|
65
65
|
console.log(' Created default .env file.');
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
// Generate wab.json — site manifest for agents
|
|
69
|
+
const wabJsonTarget = path.join(process.cwd(), 'wab.json');
|
|
70
|
+
if (fs.existsSync(wabJsonTarget)) {
|
|
71
|
+
console.log(' wab.json already exists. Skipping.');
|
|
72
|
+
} else {
|
|
73
|
+
const projectName = (() => {
|
|
74
|
+
try {
|
|
75
|
+
const pkgPath = path.join(process.cwd(), 'package.json');
|
|
76
|
+
if (fs.existsSync(pkgPath)) {
|
|
77
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
78
|
+
return pkg.name || path.basename(process.cwd());
|
|
79
|
+
}
|
|
80
|
+
} catch { /* ignore */ }
|
|
81
|
+
return path.basename(process.cwd());
|
|
82
|
+
})();
|
|
83
|
+
|
|
84
|
+
const wabJson = {
|
|
85
|
+
$schema: 'https://webagentbridge.com/schemas/wab.schema.json',
|
|
86
|
+
version: '1.0',
|
|
87
|
+
site: {
|
|
88
|
+
name: projectName,
|
|
89
|
+
domain: 'example.com',
|
|
90
|
+
description: 'A WAB-enabled site. Replace this with your real description.'
|
|
91
|
+
},
|
|
92
|
+
agentPermissions: {
|
|
93
|
+
readContent: true,
|
|
94
|
+
click: true,
|
|
95
|
+
fillForms: false,
|
|
96
|
+
scroll: true,
|
|
97
|
+
navigate: false,
|
|
98
|
+
apiAccess: false,
|
|
99
|
+
automatedLogin: false,
|
|
100
|
+
extractData: false
|
|
101
|
+
},
|
|
102
|
+
restrictions: {
|
|
103
|
+
allowedSelectors: [],
|
|
104
|
+
blockedSelectors: ['.private', '[data-private]'],
|
|
105
|
+
rateLimit: { maxCallsPerMinute: 60 }
|
|
106
|
+
},
|
|
107
|
+
actions: [
|
|
108
|
+
{
|
|
109
|
+
name: 'example_action',
|
|
110
|
+
description: 'Example action — replace with your real actions',
|
|
111
|
+
selector: '#example',
|
|
112
|
+
type: 'click'
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
logging: { enabled: true, level: 'basic' }
|
|
116
|
+
};
|
|
117
|
+
fs.writeFileSync(wabJsonTarget, JSON.stringify(wabJson, null, 2) + '\n');
|
|
118
|
+
console.log(' Created wab.json site manifest.');
|
|
119
|
+
console.log(' Edit wab.json to describe your site and actions.');
|
|
120
|
+
}
|
|
67
121
|
break;
|
|
68
122
|
}
|
|
69
123
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "web-agent-bridge",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.1",
|
|
4
4
|
"description": "Agent Transaction Bridge — the trust + transaction layer for agentic commerce. Signed intent contracts, idempotent transactions, Ed25519-verifiable receipts, explicit compensation. Plus the original WAB stack: sovereign browser, ShieldQR, SSL health, DNS discovery, agent mesh, and unified gateway for safe AI–website interaction.",
|
|
5
5
|
"author": "Web Agent Bridge <dev@webagentbridge.com>",
|
|
6
6
|
"main": "server/index.js",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Forgot password — Web Agent Bridge</title>
|
|
7
|
+
<style>body{background:#0a0e1a;color:#f0f4ff;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;margin:0;min-height:100vh}</style>
|
|
8
|
+
<link rel="stylesheet" href="/css/styles.css?v=3.0.1">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div class="auth-page">
|
|
12
|
+
<div class="auth-card fade-in">
|
|
13
|
+
<div style="text-align:center; margin-bottom:32px;">
|
|
14
|
+
<a href="/" class="navbar-brand" style="justify-content:center;">
|
|
15
|
+
<div class="brand-icon">⚡</div>
|
|
16
|
+
<span>WAB</span>
|
|
17
|
+
</a>
|
|
18
|
+
</div>
|
|
19
|
+
<h1 style="text-align:center;">Forgot password</h1>
|
|
20
|
+
<p class="subtitle" style="text-align:center;">Enter your email and we'll send you a reset link.</p>
|
|
21
|
+
|
|
22
|
+
<div class="alert alert-error" id="errorAlert"></div>
|
|
23
|
+
<div class="alert alert-success" id="successAlert" style="display:none;">If that email is registered, a reset link has been sent. Check your inbox.</div>
|
|
24
|
+
|
|
25
|
+
<form id="forgotForm">
|
|
26
|
+
<div class="form-group">
|
|
27
|
+
<label for="email">Email</label>
|
|
28
|
+
<input type="email" id="email" class="form-input" placeholder="you@example.com" required>
|
|
29
|
+
</div>
|
|
30
|
+
<button type="submit" class="btn btn-primary btn-lg">Send reset link</button>
|
|
31
|
+
</form>
|
|
32
|
+
|
|
33
|
+
<div class="auth-footer" style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;">
|
|
34
|
+
<a href="/login">Back to sign in</a>
|
|
35
|
+
<a href="/register">Create account</a>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<script>
|
|
41
|
+
document.getElementById('forgotForm').addEventListener('submit', async (e) => {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
const errorEl = document.getElementById('errorAlert');
|
|
44
|
+
const okEl = document.getElementById('successAlert');
|
|
45
|
+
errorEl.style.display = 'none';
|
|
46
|
+
okEl.style.display = 'none';
|
|
47
|
+
const email = document.getElementById('email').value;
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch('/api/auth/forgot-password', {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'Content-Type': 'application/json' },
|
|
52
|
+
body: JSON.stringify({ email })
|
|
53
|
+
});
|
|
54
|
+
const data = await res.json();
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
errorEl.textContent = data.error || 'Request failed';
|
|
57
|
+
errorEl.style.display = 'block';
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
okEl.style.display = 'block';
|
|
61
|
+
} catch (err) {
|
|
62
|
+
errorEl.textContent = 'Connection error. Please try again.';
|
|
63
|
+
errorEl.style.display = 'block';
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
</script>
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|
package/public/login.html
CHANGED
|
@@ -38,8 +38,9 @@
|
|
|
38
38
|
<button type="submit" class="btn btn-primary btn-lg">Sign In</button>
|
|
39
39
|
</form>
|
|
40
40
|
|
|
41
|
-
<div class="auth-footer">
|
|
42
|
-
|
|
41
|
+
<div class="auth-footer" style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;">
|
|
42
|
+
<a href="/forgot-password.html">Forgot password?</a>
|
|
43
|
+
<span>Don't have an account? <a href="/register">Create one</a></span>
|
|
43
44
|
</div>
|
|
44
45
|
</div>
|
|
45
46
|
</div>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Reset password — Web Agent Bridge</title>
|
|
7
|
+
<style>body{background:#0a0e1a;color:#f0f4ff;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;margin:0;min-height:100vh}</style>
|
|
8
|
+
<link rel="stylesheet" href="/css/styles.css?v=3.0.1">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div class="auth-page">
|
|
12
|
+
<div class="auth-card fade-in">
|
|
13
|
+
<div style="text-align:center; margin-bottom:32px;">
|
|
14
|
+
<a href="/" class="navbar-brand" style="justify-content:center;">
|
|
15
|
+
<div class="brand-icon">⚡</div>
|
|
16
|
+
<span>WAB</span>
|
|
17
|
+
</a>
|
|
18
|
+
</div>
|
|
19
|
+
<h1 style="text-align:center;">Reset password</h1>
|
|
20
|
+
<p class="subtitle" style="text-align:center;">Choose a new password for your account.</p>
|
|
21
|
+
|
|
22
|
+
<div class="alert alert-error" id="errorAlert"></div>
|
|
23
|
+
<div class="alert alert-success" id="successAlert" style="display:none;">Password reset. Redirecting to sign-in…</div>
|
|
24
|
+
|
|
25
|
+
<form id="resetForm">
|
|
26
|
+
<div class="form-group">
|
|
27
|
+
<label for="password">New password</label>
|
|
28
|
+
<input type="password" id="password" class="form-input" placeholder="At least 8 characters" minlength="8" maxlength="128" required>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="form-group">
|
|
31
|
+
<label for="password2">Confirm new password</label>
|
|
32
|
+
<input type="password" id="password2" class="form-input" minlength="8" maxlength="128" required>
|
|
33
|
+
</div>
|
|
34
|
+
<button type="submit" class="btn btn-primary btn-lg">Reset password</button>
|
|
35
|
+
</form>
|
|
36
|
+
|
|
37
|
+
<div class="auth-footer">
|
|
38
|
+
<a href="/login">Back to sign in</a>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<script>
|
|
44
|
+
const params = new URLSearchParams(window.location.search);
|
|
45
|
+
const token = params.get('token');
|
|
46
|
+
const errorEl = document.getElementById('errorAlert');
|
|
47
|
+
if (!token) {
|
|
48
|
+
errorEl.textContent = 'Missing reset token in URL.';
|
|
49
|
+
errorEl.style.display = 'block';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
document.getElementById('resetForm').addEventListener('submit', async (e) => {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
errorEl.style.display = 'none';
|
|
55
|
+
const okEl = document.getElementById('successAlert');
|
|
56
|
+
const pw = document.getElementById('password').value;
|
|
57
|
+
const pw2 = document.getElementById('password2').value;
|
|
58
|
+
if (pw !== pw2) {
|
|
59
|
+
errorEl.textContent = 'Passwords do not match.';
|
|
60
|
+
errorEl.style.display = 'block';
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch('/api/auth/reset-password', {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'Content-Type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({ token, password: pw })
|
|
68
|
+
});
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
errorEl.textContent = data.error || 'Reset failed';
|
|
72
|
+
errorEl.style.display = 'block';
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
okEl.style.display = 'block';
|
|
76
|
+
setTimeout(() => { window.location.href = '/login'; }, 1500);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
errorEl.textContent = 'Connection error. Please try again.';
|
|
79
|
+
errorEl.style.display = 'block';
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
</script>
|
|
83
|
+
</body>
|
|
84
|
+
</html>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Verify email — Web Agent Bridge</title>
|
|
7
|
+
<style>body{background:#0a0e1a;color:#f0f4ff;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;margin:0;min-height:100vh}</style>
|
|
8
|
+
<link rel="stylesheet" href="/css/styles.css?v=3.0.1">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div class="auth-page">
|
|
12
|
+
<div class="auth-card fade-in">
|
|
13
|
+
<div style="text-align:center; margin-bottom:32px;">
|
|
14
|
+
<a href="/" class="navbar-brand" style="justify-content:center;">
|
|
15
|
+
<div class="brand-icon">⚡</div>
|
|
16
|
+
<span>WAB</span>
|
|
17
|
+
</a>
|
|
18
|
+
</div>
|
|
19
|
+
<h1 style="text-align:center;" id="title">Verifying…</h1>
|
|
20
|
+
<p class="subtitle" style="text-align:center;" id="message">Please wait while we confirm your email.</p>
|
|
21
|
+
|
|
22
|
+
<div class="alert alert-error" id="errorAlert"></div>
|
|
23
|
+
<div class="alert alert-success" id="successAlert" style="display:none;">Email verified. You can now use all features.</div>
|
|
24
|
+
|
|
25
|
+
<div class="auth-footer" style="text-align:center;">
|
|
26
|
+
<a href="/dashboard" id="dashLink" style="display:none;">Go to dashboard</a>
|
|
27
|
+
<a href="/login" id="loginLink">Back to sign in</a>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<script>
|
|
33
|
+
(async function () {
|
|
34
|
+
const params = new URLSearchParams(window.location.search);
|
|
35
|
+
const token = params.get('token');
|
|
36
|
+
const titleEl = document.getElementById('title');
|
|
37
|
+
const msgEl = document.getElementById('message');
|
|
38
|
+
const errorEl = document.getElementById('errorAlert');
|
|
39
|
+
const okEl = document.getElementById('successAlert');
|
|
40
|
+
const dashLink = document.getElementById('dashLink');
|
|
41
|
+
|
|
42
|
+
if (!token) {
|
|
43
|
+
titleEl.textContent = 'Missing token';
|
|
44
|
+
msgEl.textContent = 'This link is invalid. Sign in and request a new verification email.';
|
|
45
|
+
errorEl.textContent = 'No verification token in URL.';
|
|
46
|
+
errorEl.style.display = 'block';
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch('/api/auth/verify-email', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({ token })
|
|
54
|
+
});
|
|
55
|
+
const data = await res.json();
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
titleEl.textContent = 'Verification failed';
|
|
58
|
+
msgEl.textContent = data.error || 'Token invalid or expired.';
|
|
59
|
+
errorEl.textContent = data.error || 'Verification failed';
|
|
60
|
+
errorEl.style.display = 'block';
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
titleEl.textContent = 'Verified ✓';
|
|
64
|
+
msgEl.textContent = 'Your email has been confirmed.';
|
|
65
|
+
okEl.style.display = 'block';
|
|
66
|
+
dashLink.style.display = 'inline';
|
|
67
|
+
} catch (err) {
|
|
68
|
+
titleEl.textContent = 'Network error';
|
|
69
|
+
msgEl.textContent = 'Please try again later.';
|
|
70
|
+
errorEl.textContent = 'Connection error.';
|
|
71
|
+
errorEl.style.display = 'block';
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
</script>
|
|
75
|
+
</body>
|
|
76
|
+
</html>
|
package/server/index.js
CHANGED
|
@@ -790,6 +790,12 @@ if (process.env.NODE_ENV !== 'test') {
|
|
|
790
790
|
// Start the Certificate Transparency Monitor (opt-in via WAB_CT_MONITOR=true).
|
|
791
791
|
try { require('./services/ssl-ct-monitor').start(); } catch (e) { console.warn('[ct-monitor] start failed:', e.message); }
|
|
792
792
|
|
|
793
|
+
// Start the ATP commission billing timer (opt-in via WAB_COMMISSION_BILLING_INTERVAL_HOURS).
|
|
794
|
+
try {
|
|
795
|
+
const r = require('./services/commission-billing').startPeriodicBilling();
|
|
796
|
+
if (r) console.log(`[commission-billing] periodic cycle every ${r.intervalHours}h`);
|
|
797
|
+
} catch (e) { console.warn('[commission-billing] start failed:', e.message); }
|
|
798
|
+
|
|
793
799
|
server.listen(PORT, () => {
|
|
794
800
|
console.log(`\n ╔══════════════════════════════════════════╗`);
|
|
795
801
|
console.log(` ║ Web Agent Bridge v${pkg.version} ║`);
|
|
@@ -47,4 +47,45 @@ function optionalAuth(req, res, next) {
|
|
|
47
47
|
next();
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
// Tier hierarchy for requireTier()
|
|
51
|
+
const TIER_ORDER = { free: 0, starter: 1, pro: 2, business: 3, enterprise: 4 };
|
|
52
|
+
|
|
53
|
+
function tierRank(t) {
|
|
54
|
+
return TIER_ORDER[String(t || 'free').toLowerCase()] ?? 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// requireTier('pro') — must be used AFTER a middleware that puts a site on req.site
|
|
58
|
+
// (e.g. requireSiteOwnership). If no req.site exists, falls back to the user's
|
|
59
|
+
// highest tier across their owned sites.
|
|
60
|
+
function requireTier(minTier) {
|
|
61
|
+
const required = tierRank(minTier);
|
|
62
|
+
return (req, res, next) => {
|
|
63
|
+
let actualTier = 'free';
|
|
64
|
+
if (req.site && req.site.tier) {
|
|
65
|
+
actualTier = req.site.tier;
|
|
66
|
+
} else if (req.user && req.user.id) {
|
|
67
|
+
try {
|
|
68
|
+
const { findSitesByUser } = require('../models/db');
|
|
69
|
+
const sites = findSitesByUser.all(req.user.id) || [];
|
|
70
|
+
for (const s of sites) {
|
|
71
|
+
if (tierRank(s.tier) > tierRank(actualTier)) actualTier = s.tier;
|
|
72
|
+
}
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// DB layer may not be ready in tests — be permissive there.
|
|
75
|
+
if (process.env.NODE_ENV === 'test') return next();
|
|
76
|
+
return res.status(500).json({ error: 'Tier lookup failed' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (tierRank(actualTier) < required) {
|
|
80
|
+
return res.status(402).json({
|
|
81
|
+
error: 'Plan upgrade required',
|
|
82
|
+
required_tier: minTier,
|
|
83
|
+
current_tier: actualTier,
|
|
84
|
+
upgrade_url: '/premium.html'
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
next();
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { generateToken, authenticateToken, optionalAuth, requireTier };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- Email verification + password reset
|
|
2
|
+
ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0;
|
|
3
|
+
ALTER TABLE users ADD COLUMN email_verified_at TEXT;
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
|
6
|
+
token_hash TEXT PRIMARY KEY,
|
|
7
|
+
user_id TEXT NOT NULL,
|
|
8
|
+
expires_at TEXT NOT NULL,
|
|
9
|
+
used_at TEXT,
|
|
10
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
11
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id);
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_prt_expires ON password_reset_tokens(expires_at);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
|
18
|
+
token_hash TEXT PRIMARY KEY,
|
|
19
|
+
user_id TEXT NOT NULL,
|
|
20
|
+
expires_at TEXT NOT NULL,
|
|
21
|
+
used_at TEXT,
|
|
22
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
23
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_evt_user ON email_verification_tokens(user_id);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_evt_expires ON email_verification_tokens(expires_at);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
-- Migration 023 — ATP Merchant Commission (v3.10.0)
|
|
3
|
+
--
|
|
4
|
+
-- WAB takes a small platform commission (default 0.1% / 10 bps) on every
|
|
5
|
+
-- successful merchant transaction settled through ATP on a paid plan.
|
|
6
|
+
-- Free-tier sites and platform self-payments are exempt.
|
|
7
|
+
--
|
|
8
|
+
-- One row per settled atp_transactions.id. State machine:
|
|
9
|
+
-- pending → newly recorded
|
|
10
|
+
-- invoiced → rolled into a Stripe invoice / payout cycle
|
|
11
|
+
-- collected → billed and paid by merchant
|
|
12
|
+
-- refunded → underlying tx was compensated
|
|
13
|
+
-- waived → manually waived by an admin
|
|
14
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
CREATE TABLE IF NOT EXISTS atp_commissions (
|
|
17
|
+
id TEXT PRIMARY KEY, -- atp_com_<ulid>
|
|
18
|
+
transaction_id TEXT NOT NULL UNIQUE, -- one commission per tx
|
|
19
|
+
intent_id TEXT NOT NULL,
|
|
20
|
+
merchant_user_id TEXT NOT NULL, -- the site owner
|
|
21
|
+
merchant_site_id TEXT,
|
|
22
|
+
merchant_tier TEXT NOT NULL, -- snapshot at charge time
|
|
23
|
+
gross_amount_cents INTEGER NOT NULL,
|
|
24
|
+
currency TEXT NOT NULL DEFAULT 'EUR',
|
|
25
|
+
commission_bps INTEGER NOT NULL DEFAULT 10, -- 10 bps = 0.10%
|
|
26
|
+
commission_cents INTEGER NOT NULL DEFAULT 0,
|
|
27
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
28
|
+
CHECK (status IN ('pending','invoiced','collected','refunded','waived')),
|
|
29
|
+
external_ref TEXT, -- payment gateway ref (PI id, etc.)
|
|
30
|
+
notes TEXT,
|
|
31
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
32
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
33
|
+
FOREIGN KEY (transaction_id) REFERENCES atp_transactions(id) ON DELETE CASCADE
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_atp_commissions_merchant
|
|
37
|
+
ON atp_commissions(merchant_user_id, created_at DESC);
|
|
38
|
+
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_atp_commissions_site
|
|
40
|
+
ON atp_commissions(merchant_site_id, created_at DESC);
|
|
41
|
+
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_atp_commissions_status
|
|
43
|
+
ON atp_commissions(status, created_at DESC);
|
package/server/models/db.js
CHANGED
|
@@ -674,6 +674,74 @@ function getAdStats() {
|
|
|
674
674
|
return { total, pending, approved, totalImpressions, totalClicks, totalRevenueCents };
|
|
675
675
|
}
|
|
676
676
|
|
|
677
|
+
// ─── Password Reset & Email Verification ──────────────────────────────
|
|
678
|
+
// Prepared statements are lazy because the tables are created by migration 022.
|
|
679
|
+
|
|
680
|
+
let _stmts = null;
|
|
681
|
+
function _authStmts() {
|
|
682
|
+
if (_stmts) return _stmts;
|
|
683
|
+
_stmts = {
|
|
684
|
+
insertPRT: db.prepare(`INSERT INTO password_reset_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)`),
|
|
685
|
+
findPRT: db.prepare(`SELECT * FROM password_reset_tokens WHERE token_hash = ?`),
|
|
686
|
+
usePRT: db.prepare(`UPDATE password_reset_tokens SET used_at = datetime('now') WHERE token_hash = ? AND used_at IS NULL`),
|
|
687
|
+
delPRTForUser: db.prepare(`DELETE FROM password_reset_tokens WHERE user_id = ?`),
|
|
688
|
+
updateUserPassword: db.prepare(`UPDATE users SET password = ?, updated_at = datetime('now') WHERE id = ?`),
|
|
689
|
+
|
|
690
|
+
insertEVT: db.prepare(`INSERT INTO email_verification_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)`),
|
|
691
|
+
findEVT: db.prepare(`SELECT * FROM email_verification_tokens WHERE token_hash = ?`),
|
|
692
|
+
useEVT: db.prepare(`UPDATE email_verification_tokens SET used_at = datetime('now') WHERE token_hash = ? AND used_at IS NULL`),
|
|
693
|
+
delEVTForUser: db.prepare(`DELETE FROM email_verification_tokens WHERE user_id = ?`),
|
|
694
|
+
markVerified: db.prepare(`UPDATE users SET email_verified = 1, email_verified_at = datetime('now') WHERE id = ?`),
|
|
695
|
+
isVerified: db.prepare(`SELECT email_verified FROM users WHERE id = ?`)
|
|
696
|
+
};
|
|
697
|
+
return _stmts;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function createPasswordResetToken({ userId, tokenHash, ttlMinutes = 60 }) {
|
|
701
|
+
const expires = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
|
|
702
|
+
_authStmts().delPRTForUser.run(userId); // invalidate previous
|
|
703
|
+
_authStmts().insertPRT.run(tokenHash, userId, expires);
|
|
704
|
+
return expires;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function consumePasswordResetToken(tokenHash) {
|
|
708
|
+
const row = _authStmts().findPRT.get(tokenHash);
|
|
709
|
+
if (!row) return null;
|
|
710
|
+
if (row.used_at) return null;
|
|
711
|
+
if (new Date(row.expires_at).getTime() < Date.now()) return null;
|
|
712
|
+
const r = _authStmts().usePRT.run(tokenHash);
|
|
713
|
+
if (r.changes === 0) return null;
|
|
714
|
+
return row.user_id;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function updateUserPassword(userId, plainPassword) {
|
|
718
|
+
const hashed = bcrypt.hashSync(plainPassword, 12);
|
|
719
|
+
_authStmts().updateUserPassword.run(hashed, userId);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function createEmailVerificationToken({ userId, tokenHash, ttlMinutes = 60 * 24 * 7 }) {
|
|
723
|
+
const expires = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
|
|
724
|
+
_authStmts().delEVTForUser.run(userId);
|
|
725
|
+
_authStmts().insertEVT.run(tokenHash, userId, expires);
|
|
726
|
+
return expires;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function consumeEmailVerificationToken(tokenHash) {
|
|
730
|
+
const row = _authStmts().findEVT.get(tokenHash);
|
|
731
|
+
if (!row) return null;
|
|
732
|
+
if (row.used_at) return null;
|
|
733
|
+
if (new Date(row.expires_at).getTime() < Date.now()) return null;
|
|
734
|
+
const r = _authStmts().useEVT.run(tokenHash);
|
|
735
|
+
if (r.changes === 0) return null;
|
|
736
|
+
_authStmts().markVerified.run(row.user_id);
|
|
737
|
+
return row.user_id;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function isEmailVerified(userId) {
|
|
741
|
+
const row = _authStmts().isVerified.get(userId);
|
|
742
|
+
return !!(row && row.email_verified);
|
|
743
|
+
}
|
|
744
|
+
|
|
677
745
|
module.exports = {
|
|
678
746
|
db,
|
|
679
747
|
registerUser,
|
|
@@ -736,5 +804,12 @@ module.exports = {
|
|
|
736
804
|
updateAdStatus,
|
|
737
805
|
deleteAd,
|
|
738
806
|
recordAdEvent,
|
|
739
|
-
getAdStats
|
|
807
|
+
getAdStats,
|
|
808
|
+
// Auth recovery & verification
|
|
809
|
+
createPasswordResetToken,
|
|
810
|
+
consumePasswordResetToken,
|
|
811
|
+
updateUserPassword,
|
|
812
|
+
createEmailVerificationToken,
|
|
813
|
+
consumeEmailVerificationToken,
|
|
814
|
+
isEmailVerified
|
|
740
815
|
};
|
package/server/routes/admin.js
CHANGED
|
@@ -597,4 +597,83 @@ router.post('/governance/approvals/:rid/decide', authenticateAdmin, (req, res) =
|
|
|
597
597
|
res.json(out);
|
|
598
598
|
});
|
|
599
599
|
|
|
600
|
+
// ─── ATP Merchant Commission (platform-wide view) ──────────────────────
|
|
601
|
+
const _commissions = require('../services/commissions');
|
|
602
|
+
|
|
603
|
+
router.get('/commissions/stats', authenticateAdmin, (req, res) => {
|
|
604
|
+
try {
|
|
605
|
+
res.json({ ok: true, data: _commissions.getPlatformCommissionStats() });
|
|
606
|
+
} catch (e) {
|
|
607
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
router.get('/commissions', authenticateAdmin, (req, res) => {
|
|
612
|
+
const limit = Math.min(500, Math.max(1, parseInt(req.query.limit, 10) || 100));
|
|
613
|
+
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
|
|
614
|
+
const status = req.query.status || null;
|
|
615
|
+
try {
|
|
616
|
+
let rows;
|
|
617
|
+
if (status) {
|
|
618
|
+
rows = db.prepare(`
|
|
619
|
+
SELECT c.*, u.email AS merchant_email, s.domain AS merchant_domain
|
|
620
|
+
FROM atp_commissions c
|
|
621
|
+
LEFT JOIN users u ON u.id = c.merchant_user_id
|
|
622
|
+
LEFT JOIN sites s ON s.id = c.merchant_site_id
|
|
623
|
+
WHERE c.status = ?
|
|
624
|
+
ORDER BY c.created_at DESC LIMIT ? OFFSET ?
|
|
625
|
+
`).all(status, limit, offset);
|
|
626
|
+
} else {
|
|
627
|
+
rows = db.prepare(`
|
|
628
|
+
SELECT c.*, u.email AS merchant_email, s.domain AS merchant_domain
|
|
629
|
+
FROM atp_commissions c
|
|
630
|
+
LEFT JOIN users u ON u.id = c.merchant_user_id
|
|
631
|
+
LEFT JOIN sites s ON s.id = c.merchant_site_id
|
|
632
|
+
ORDER BY c.created_at DESC LIMIT ? OFFSET ?
|
|
633
|
+
`).all(limit, offset);
|
|
634
|
+
}
|
|
635
|
+
res.json({ ok: true, data: rows, limit, offset });
|
|
636
|
+
} catch (e) {
|
|
637
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
router.post('/commissions/:id/status', authenticateAdmin, (req, res) => {
|
|
642
|
+
const next = String(req.body?.status || '').toLowerCase();
|
|
643
|
+
const allowed = ['pending', 'invoiced', 'collected', 'refunded', 'waived'];
|
|
644
|
+
if (!allowed.includes(next)) {
|
|
645
|
+
return res.status(400).json({ ok: false, error: `status must be one of ${allowed.join(',')}` });
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
const r = db.prepare(`
|
|
649
|
+
UPDATE atp_commissions
|
|
650
|
+
SET status=?, notes = COALESCE(notes || ' | ', '') || ?, updated_at = datetime('now')
|
|
651
|
+
WHERE id=?
|
|
652
|
+
`).run(next, `admin:${req.admin.id} → ${next}`, req.params.id);
|
|
653
|
+
if (r.changes === 0) return res.status(404).json({ ok: false, error: 'not_found' });
|
|
654
|
+
auditLog({ actorType: 'admin', actorId: String(req.admin.id), action: 'commission_status_update', details: { id: req.params.id, status: next } });
|
|
655
|
+
res.json({ ok: true });
|
|
656
|
+
} catch (e) {
|
|
657
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// Run a billing cycle (turn `pending` rows into Stripe invoices).
|
|
662
|
+
// ?dry_run=1 returns the plan without touching Stripe or the DB.
|
|
663
|
+
router.post('/commissions/run-billing', authenticateAdmin, async (req, res) => {
|
|
664
|
+
const dryRun = req.query.dry_run === '1' || req.body?.dry_run === true;
|
|
665
|
+
try {
|
|
666
|
+
const billing = require('../services/commission-billing');
|
|
667
|
+
const summary = await billing.runBillingCycle({ dryRun });
|
|
668
|
+
auditLog({
|
|
669
|
+
actorType: 'admin', actorId: String(req.admin.id),
|
|
670
|
+
action: 'commission_billing_cycle',
|
|
671
|
+
details: { dry_run: dryRun, batches_billed: summary.batches_billed, rows_invoiced: summary.rows_invoiced, total_cents: summary.total_commission_cents },
|
|
672
|
+
});
|
|
673
|
+
res.json({ ok: true, data: summary });
|
|
674
|
+
} catch (e) {
|
|
675
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
600
679
|
module.exports = router;
|