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 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.9.2",
3
+ "version": "3.10.0",
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
- Don't have an account? <a href="/register">Create one</a>
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>
@@ -47,4 +47,45 @@ function optionalAuth(req, res, next) {
47
47
  next();
48
48
  }
49
49
 
50
- module.exports = { generateToken, authenticateToken, optionalAuth };
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);
@@ -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
  };
@@ -597,4 +597,65 @@ 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
+
600
661
  module.exports = router;