web-agent-bridge 1.0.0 → 1.1.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/README.ar.md +1 -1
- package/README.md +336 -36
- package/docs/DEPLOY.md +118 -0
- package/docs/SPEC.md +1540 -0
- package/examples/mcp-agent.js +94 -0
- package/examples/vision-agent.js +12 -0
- package/package.json +14 -3
- package/public/admin/dashboard.html +848 -0
- package/public/admin/login.html +84 -0
- package/public/cookies.html +208 -0
- package/public/css/premium.css +317 -0
- package/public/dashboard.html +138 -0
- package/public/docs.html +5 -2
- package/public/index.html +54 -28
- package/public/js/auth-nav.js +31 -0
- package/public/js/auth-redirect.js +12 -0
- package/public/js/cookie-consent.js +56 -0
- package/public/js/ws-client.js +74 -0
- package/public/login.html +4 -2
- package/public/premium-dashboard.html +2075 -0
- package/public/premium.html +791 -0
- package/public/privacy.html +295 -0
- package/public/register.html +11 -2
- package/public/terms.html +254 -0
- package/script/ai-agent-bridge.js +253 -22
- package/sdk/index.js +36 -0
- package/server/config/secrets.js +92 -0
- package/server/index.js +102 -26
- package/server/middleware/adminAuth.js +30 -0
- package/server/middleware/auth.js +4 -7
- package/server/middleware/rateLimits.js +24 -0
- package/server/migrations/001_add_analytics_indexes.sql +7 -0
- package/server/migrations/002_premium_features.sql +418 -0
- package/server/models/db.js +360 -4
- package/server/routes/admin.js +247 -0
- package/server/routes/api.js +26 -9
- package/server/routes/billing.js +45 -0
- package/server/routes/discovery.js +329 -0
- package/server/routes/license.js +200 -11
- package/server/routes/noscript.js +543 -0
- package/server/routes/premium.js +724 -0
- package/server/routes/wab-api.js +476 -0
- package/server/services/email.js +204 -0
- package/server/services/fairness.js +420 -0
- package/server/services/premium.js +1680 -0
- package/server/services/stripe.js +192 -0
- package/server/utils/cache.js +125 -0
- package/server/utils/migrate.js +81 -0
- package/server/utils/secureFields.js +50 -0
- package/server/ws.js +33 -13
- package/wab-mcp-adapter/README.md +136 -0
- package/wab-mcp-adapter/index.js +555 -0
- package/wab-mcp-adapter/package.json +17 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe Payment Integration Service
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
getPlatformSetting,
|
|
7
|
+
saveStripeCustomer,
|
|
8
|
+
getStripeCustomer,
|
|
9
|
+
saveStripeSubscription,
|
|
10
|
+
updateStripeSubscription,
|
|
11
|
+
getStripeSubscriptionBySubId,
|
|
12
|
+
savePayment,
|
|
13
|
+
updateSiteTier,
|
|
14
|
+
findSiteById
|
|
15
|
+
} = require('../models/db');
|
|
16
|
+
|
|
17
|
+
let stripe = null;
|
|
18
|
+
|
|
19
|
+
function getStripe() {
|
|
20
|
+
if (stripe) return stripe;
|
|
21
|
+
const key = process.env.STRIPE_SECRET_KEY || getPlatformSetting('stripe_secret_key');
|
|
22
|
+
if (!key) return null;
|
|
23
|
+
stripe = require('stripe')(key);
|
|
24
|
+
return stripe;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getStripePrices() {
|
|
28
|
+
return {
|
|
29
|
+
starter: process.env.STRIPE_PRICE_STARTER || getPlatformSetting('stripe_price_starter'),
|
|
30
|
+
pro: process.env.STRIPE_PRICE_PRO || getPlatformSetting('stripe_price_pro'),
|
|
31
|
+
enterprise: process.env.STRIPE_PRICE_ENTERPRISE || getPlatformSetting('stripe_price_enterprise')
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function createCheckoutSession({ userId, userEmail, siteId, tier }) {
|
|
36
|
+
const site = findSiteById.get(siteId);
|
|
37
|
+
if (!site || site.user_id !== userId) {
|
|
38
|
+
throw new Error('Site not found or access denied');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const s = getStripe();
|
|
42
|
+
if (!s) throw new Error('Stripe not configured');
|
|
43
|
+
|
|
44
|
+
const prices = getStripePrices();
|
|
45
|
+
const priceId = prices[tier];
|
|
46
|
+
if (!priceId) throw new Error(`No price configured for tier: ${tier}`);
|
|
47
|
+
|
|
48
|
+
// Get or create Stripe customer
|
|
49
|
+
let customer = getStripeCustomer(userId);
|
|
50
|
+
if (!customer) {
|
|
51
|
+
const stripeCustomer = await s.customers.create({ email: userEmail, metadata: { wab_user_id: userId } });
|
|
52
|
+
saveStripeCustomer(userId, stripeCustomer.id);
|
|
53
|
+
customer = { stripe_customer_id: stripeCustomer.id };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const baseUrl = process.env.BASE_URL || 'https://webagentbridge.com';
|
|
57
|
+
|
|
58
|
+
const session = await s.checkout.sessions.create({
|
|
59
|
+
customer: customer.stripe_customer_id,
|
|
60
|
+
mode: 'subscription',
|
|
61
|
+
payment_method_types: ['card'],
|
|
62
|
+
line_items: [{ price: priceId, quantity: 1 }],
|
|
63
|
+
metadata: { wab_user_id: userId, wab_site_id: siteId, tier },
|
|
64
|
+
success_url: `${baseUrl}/dashboard?payment=success&session_id={CHECKOUT_SESSION_ID}`,
|
|
65
|
+
cancel_url: `${baseUrl}/dashboard?payment=cancelled`
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return { sessionId: session.id, url: session.url };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function createPortalSession(userId) {
|
|
72
|
+
const s = getStripe();
|
|
73
|
+
if (!s) throw new Error('Stripe not configured');
|
|
74
|
+
|
|
75
|
+
const customer = getStripeCustomer(userId);
|
|
76
|
+
if (!customer) throw new Error('No Stripe customer found');
|
|
77
|
+
|
|
78
|
+
const baseUrl = process.env.BASE_URL || 'https://webagentbridge.com';
|
|
79
|
+
|
|
80
|
+
const session = await s.billingPortal.sessions.create({
|
|
81
|
+
customer: customer.stripe_customer_id,
|
|
82
|
+
return_url: `${baseUrl}/dashboard`
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return { url: session.url };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function handleWebhookEvent(event) {
|
|
89
|
+
switch (event.type) {
|
|
90
|
+
case 'checkout.session.completed': {
|
|
91
|
+
const session = event.data.object;
|
|
92
|
+
const { wab_user_id, wab_site_id, tier } = session.metadata || {};
|
|
93
|
+
if (wab_user_id && wab_site_id && session.subscription) {
|
|
94
|
+
saveStripeSubscription({
|
|
95
|
+
userId: wab_user_id,
|
|
96
|
+
siteId: wab_site_id,
|
|
97
|
+
stripeSubId: session.subscription,
|
|
98
|
+
stripePriceId: null,
|
|
99
|
+
tier: tier || 'starter',
|
|
100
|
+
status: 'active',
|
|
101
|
+
periodStart: new Date().toISOString(),
|
|
102
|
+
periodEnd: null
|
|
103
|
+
});
|
|
104
|
+
updateSiteTier.run(tier || 'starter', wab_site_id, wab_user_id);
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
case 'invoice.payment_succeeded': {
|
|
110
|
+
const invoice = event.data.object;
|
|
111
|
+
if (invoice.subscription) {
|
|
112
|
+
const sub = getStripeSubscriptionBySubId(invoice.subscription);
|
|
113
|
+
if (sub) {
|
|
114
|
+
savePayment({
|
|
115
|
+
userId: sub.user_id,
|
|
116
|
+
stripePaymentId: invoice.payment_intent,
|
|
117
|
+
amount: invoice.amount_paid,
|
|
118
|
+
currency: invoice.currency,
|
|
119
|
+
status: 'succeeded',
|
|
120
|
+
description: `Subscription payment - ${sub.tier}`
|
|
121
|
+
});
|
|
122
|
+
updateStripeSubscription(invoice.subscription, {
|
|
123
|
+
status: 'active',
|
|
124
|
+
periodStart: new Date(invoice.period_start * 1000).toISOString(),
|
|
125
|
+
periodEnd: new Date(invoice.period_end * 1000).toISOString()
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case 'customer.subscription.updated': {
|
|
133
|
+
const subscription = event.data.object;
|
|
134
|
+
updateStripeSubscription(subscription.id, {
|
|
135
|
+
status: subscription.status === 'active' ? 'active' : subscription.status === 'past_due' ? 'past_due' : subscription.status === 'trialing' ? 'trialing' : 'cancelled'
|
|
136
|
+
});
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
case 'customer.subscription.deleted': {
|
|
141
|
+
const subscription = event.data.object;
|
|
142
|
+
const sub = getStripeSubscriptionBySubId(subscription.id);
|
|
143
|
+
if (sub) {
|
|
144
|
+
updateStripeSubscription(subscription.id, { status: 'cancelled' });
|
|
145
|
+
updateSiteTier.run('free', sub.site_id, sub.user_id);
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case 'invoice.payment_failed': {
|
|
151
|
+
const invoice = event.data.object;
|
|
152
|
+
if (invoice.subscription) {
|
|
153
|
+
updateStripeSubscription(invoice.subscription, { status: 'past_due' });
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isStripeConfigured() {
|
|
161
|
+
const key = process.env.STRIPE_SECRET_KEY || getPlatformSetting('stripe_secret_key');
|
|
162
|
+
return !!key;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Express webhook handler: verifies Stripe signature, then dispatches business logic.
|
|
167
|
+
*/
|
|
168
|
+
function handleWebhookRequest(req) {
|
|
169
|
+
const sig = req.headers['stripe-signature'];
|
|
170
|
+
const raw = req.body;
|
|
171
|
+
const whSecret = process.env.STRIPE_WEBHOOK_SECRET || getPlatformSetting('stripe_webhook_secret');
|
|
172
|
+
if (!whSecret) {
|
|
173
|
+
throw new Error('Stripe webhook secret not configured (STRIPE_WEBHOOK_SECRET or platform stripe_webhook_secret)');
|
|
174
|
+
}
|
|
175
|
+
if (!sig) {
|
|
176
|
+
throw new Error('Missing Stripe-Signature header');
|
|
177
|
+
}
|
|
178
|
+
const s = getStripe();
|
|
179
|
+
if (!s) throw new Error('Stripe not configured');
|
|
180
|
+
const event = s.webhooks.constructEvent(raw, sig, whSecret);
|
|
181
|
+
handleWebhookEvent(event);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
getStripe,
|
|
186
|
+
createCheckoutSession,
|
|
187
|
+
createPortalSession,
|
|
188
|
+
handleWebhookEvent,
|
|
189
|
+
handleWebhookRequest,
|
|
190
|
+
isStripeConfigured,
|
|
191
|
+
getStripePrices
|
|
192
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAB Caching Layer — In-memory cache with TTL for hot data
|
|
3
|
+
* Reduces DB reads for license verification, config, and stats
|
|
4
|
+
*/
|
|
5
|
+
class Cache {
|
|
6
|
+
constructor(defaultTTL = 60000) {
|
|
7
|
+
this.store = new Map();
|
|
8
|
+
this.defaultTTL = defaultTTL;
|
|
9
|
+
this._interval = setInterval(() => this._cleanup(), 120000);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
get(key) {
|
|
13
|
+
const entry = this.store.get(key);
|
|
14
|
+
if (!entry) return undefined;
|
|
15
|
+
if (Date.now() > entry.expiresAt) {
|
|
16
|
+
this.store.delete(key);
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
entry.hits++;
|
|
20
|
+
return entry.value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
set(key, value, ttl) {
|
|
24
|
+
this.store.set(key, {
|
|
25
|
+
value,
|
|
26
|
+
expiresAt: Date.now() + (ttl || this.defaultTTL),
|
|
27
|
+
hits: 0
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
del(key) {
|
|
32
|
+
this.store.delete(key);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
invalidatePattern(pattern) {
|
|
36
|
+
for (const key of this.store.keys()) {
|
|
37
|
+
if (key.includes(pattern)) this.store.delete(key);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
stats() {
|
|
42
|
+
return {
|
|
43
|
+
size: this.store.size,
|
|
44
|
+
keys: Array.from(this.store.keys())
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_cleanup() {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
for (const [key, entry] of this.store) {
|
|
51
|
+
if (now > entry.expiresAt) this.store.delete(key);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
destroy() {
|
|
56
|
+
clearInterval(this._interval);
|
|
57
|
+
this.store.clear();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Analytics Queue — Batches analytics inserts for better write performance
|
|
63
|
+
* Flushes every N seconds or when buffer reaches max size.
|
|
64
|
+
* maxBufferTotal caps memory if DB writes fail repeatedly (DoS mitigation).
|
|
65
|
+
*/
|
|
66
|
+
class AnalyticsQueue {
|
|
67
|
+
constructor(db, options = {}) {
|
|
68
|
+
this.db = db;
|
|
69
|
+
this.buffer = [];
|
|
70
|
+
this.maxSize = options.maxSize || 50;
|
|
71
|
+
this.maxBufferTotal = options.maxBufferTotal || 5000;
|
|
72
|
+
this.flushInterval = options.flushInterval || 5000;
|
|
73
|
+
this._timer = setInterval(() => this.flush(), this.flushInterval);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
push(analytic) {
|
|
77
|
+
while (this.buffer.length >= this.maxBufferTotal) {
|
|
78
|
+
this.buffer.shift();
|
|
79
|
+
console.warn('[WAB] Analytics buffer at cap; dropped oldest event');
|
|
80
|
+
}
|
|
81
|
+
this.buffer.push(analytic);
|
|
82
|
+
if (this.buffer.length >= this.maxSize) {
|
|
83
|
+
this.flush();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
flush() {
|
|
88
|
+
if (this.buffer.length === 0) return;
|
|
89
|
+
const batch = this.buffer.splice(0);
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const insert = this.db.prepare(
|
|
93
|
+
`INSERT INTO analytics (site_id, action_name, agent_id, trigger_type, success, metadata) VALUES (?, ?, ?, ?, ?, ?)`
|
|
94
|
+
);
|
|
95
|
+
const insertMany = this.db.transaction((items) => {
|
|
96
|
+
for (const item of items) {
|
|
97
|
+
insert.run(
|
|
98
|
+
item.siteId,
|
|
99
|
+
item.actionName,
|
|
100
|
+
item.agentId || null,
|
|
101
|
+
item.triggerType || null,
|
|
102
|
+
item.success ? 1 : 0,
|
|
103
|
+
JSON.stringify(item.metadata || {})
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
insertMany(batch);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error('[WAB Cache] Analytics batch insert failed:', err.message);
|
|
110
|
+
while (this.buffer.length + batch.length > this.maxBufferTotal) {
|
|
111
|
+
batch.shift();
|
|
112
|
+
}
|
|
113
|
+
this.buffer.unshift(...batch);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
destroy() {
|
|
118
|
+
clearInterval(this._timer);
|
|
119
|
+
this.flush();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const cache = new Cache(60000);
|
|
124
|
+
|
|
125
|
+
module.exports = { Cache, AnalyticsQueue, cache };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Migration Runner
|
|
3
|
+
* Tracks and applies SQL migrations from server/migrations/ in order.
|
|
4
|
+
* Uses a `_migrations` table to record applied migrations.
|
|
5
|
+
*/
|
|
6
|
+
const Database = require('better-sqlite3');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
|
|
10
|
+
const DATA_DIR = process.env.NODE_ENV === 'test'
|
|
11
|
+
? path.join(__dirname, '..', '..', 'data-test')
|
|
12
|
+
: path.join(__dirname, '..', '..', 'data');
|
|
13
|
+
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
14
|
+
|
|
15
|
+
const dbFile = process.env.NODE_ENV === 'test' ? 'wab-test.db' : 'wab.db';
|
|
16
|
+
const db = new Database(path.join(DATA_DIR, dbFile));
|
|
17
|
+
|
|
18
|
+
// Ensure migrations tracking table exists
|
|
19
|
+
db.exec(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
21
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
22
|
+
name TEXT UNIQUE NOT NULL,
|
|
23
|
+
applied_at TEXT DEFAULT (datetime('now'))
|
|
24
|
+
);
|
|
25
|
+
`);
|
|
26
|
+
|
|
27
|
+
const MIGRATIONS_DIR = path.join(__dirname, '..', 'migrations');
|
|
28
|
+
|
|
29
|
+
function getAppliedMigrations() {
|
|
30
|
+
return new Set(
|
|
31
|
+
db.prepare('SELECT name FROM _migrations ORDER BY id').all().map(r => r.name)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runMigrations() {
|
|
36
|
+
if (!fs.existsSync(MIGRATIONS_DIR)) {
|
|
37
|
+
console.log('No migrations directory found.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const files = fs.readdirSync(MIGRATIONS_DIR)
|
|
42
|
+
.filter(f => f.endsWith('.sql'))
|
|
43
|
+
.sort();
|
|
44
|
+
|
|
45
|
+
const applied = getAppliedMigrations();
|
|
46
|
+
let count = 0;
|
|
47
|
+
|
|
48
|
+
const applyMigration = db.transaction((name, sql) => {
|
|
49
|
+
db.exec(sql);
|
|
50
|
+
db.prepare('INSERT INTO _migrations (name) VALUES (?)').run(name);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
if (applied.has(file)) continue;
|
|
55
|
+
|
|
56
|
+
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf8');
|
|
57
|
+
try {
|
|
58
|
+
applyMigration(file, sql);
|
|
59
|
+
console.log(` ✅ Migration applied: ${file}`);
|
|
60
|
+
count++;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error(` ❌ Migration failed: ${file} — ${err.message}`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (count === 0) {
|
|
68
|
+
console.log(' All migrations up to date.');
|
|
69
|
+
} else {
|
|
70
|
+
console.log(` ${count} migration(s) applied.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Run when called directly: node server/utils/migrate.js
|
|
75
|
+
if (require.main === module) {
|
|
76
|
+
console.log('Running database migrations...');
|
|
77
|
+
runMigrations();
|
|
78
|
+
db.close();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { runMigrations };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional AES-256-GCM encryption for sensitive DB fields (e.g. SMTP password).
|
|
3
|
+
* Set CREDENTIALS_ENCRYPTION_KEY (any long random string) to enable at-rest encryption.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
const PREFIX = 'enc:v1:';
|
|
9
|
+
|
|
10
|
+
function getKey() {
|
|
11
|
+
const raw = process.env.CREDENTIALS_ENCRYPTION_KEY;
|
|
12
|
+
if (!raw || String(raw).length < 8) return null;
|
|
13
|
+
return crypto.createHash('sha256').update(String(raw)).digest();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function encryptOptional(plain) {
|
|
17
|
+
if (plain == null || plain === '') return plain;
|
|
18
|
+
const key = getKey();
|
|
19
|
+
if (!key) return plain;
|
|
20
|
+
const iv = crypto.randomBytes(12);
|
|
21
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
22
|
+
const enc = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
|
23
|
+
const tag = cipher.getAuthTag();
|
|
24
|
+
return `${PREFIX}${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function decryptOptional(stored) {
|
|
28
|
+
if (stored == null || stored === '') return stored;
|
|
29
|
+
if (typeof stored !== 'string' || !stored.startsWith(PREFIX)) return stored;
|
|
30
|
+
const key = getKey();
|
|
31
|
+
if (!key) {
|
|
32
|
+
console.warn('[WAB] CREDENTIALS_ENCRYPTION_KEY missing; cannot decrypt stored credential');
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const rest = stored.slice(PREFIX.length);
|
|
37
|
+
const [ivHex, tagHex, dataHex] = rest.split(':');
|
|
38
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
39
|
+
const tag = Buffer.from(tagHex, 'hex');
|
|
40
|
+
const data = Buffer.from(dataHex, 'hex');
|
|
41
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
42
|
+
decipher.setAuthTag(tag);
|
|
43
|
+
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.error('[WAB] Decrypt failed:', e.message);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { encryptOptional, decryptOptional };
|
package/server/ws.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const WebSocket = require('ws');
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
|
2
|
+
const { verifyUserToken, verifyAdminToken } = require('./config/secrets');
|
|
3
|
+
const { findSiteById } = require('./models/db');
|
|
5
4
|
|
|
6
5
|
// Map of siteId → Set of WebSocket clients
|
|
7
6
|
const siteClients = new Map();
|
|
@@ -20,16 +19,39 @@ function setupWebSocket(server) {
|
|
|
20
19
|
const msg = JSON.parse(data);
|
|
21
20
|
|
|
22
21
|
if (msg.type === 'auth') {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
if (!msg.token || !msg.siteId) {
|
|
23
|
+
ws.send(JSON.stringify({ type: 'error', message: 'token and siteId required' }));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let decoded;
|
|
28
|
+
let isAdmin = false;
|
|
29
|
+
try {
|
|
30
|
+
decoded = verifyUserToken(msg.token);
|
|
31
|
+
} catch {
|
|
32
|
+
try {
|
|
33
|
+
decoded = verifyAdminToken(msg.token);
|
|
34
|
+
isAdmin = decoded.isAdmin === true;
|
|
35
|
+
} catch {
|
|
36
|
+
ws.send(JSON.stringify({ type: 'error', message: 'Invalid message or auth failed' }));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!isAdmin) {
|
|
42
|
+
const site = findSiteById.get(msg.siteId);
|
|
43
|
+
if (!site || site.user_id !== decoded.id) {
|
|
44
|
+
ws.send(JSON.stringify({ type: 'error', message: 'Forbidden: not your site' }));
|
|
45
|
+
return;
|
|
29
46
|
}
|
|
30
|
-
siteClients.get(msg.siteId).add(ws);
|
|
31
|
-
ws.send(JSON.stringify({ type: 'auth:success', siteId: msg.siteId }));
|
|
32
47
|
}
|
|
48
|
+
|
|
49
|
+
authenticatedSiteId = msg.siteId;
|
|
50
|
+
if (!siteClients.has(msg.siteId)) {
|
|
51
|
+
siteClients.set(msg.siteId, new Set());
|
|
52
|
+
}
|
|
53
|
+
siteClients.get(msg.siteId).add(ws);
|
|
54
|
+
ws.send(JSON.stringify({ type: 'auth:success', siteId: msg.siteId }));
|
|
33
55
|
}
|
|
34
56
|
} catch (e) {
|
|
35
57
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid message or auth failed' }));
|
|
@@ -46,7 +68,6 @@ function setupWebSocket(server) {
|
|
|
46
68
|
});
|
|
47
69
|
});
|
|
48
70
|
|
|
49
|
-
// Heartbeat to clean up dead connections
|
|
50
71
|
const interval = setInterval(() => {
|
|
51
72
|
wss.clients.forEach((ws) => {
|
|
52
73
|
if (!ws.isAlive) return ws.terminate();
|
|
@@ -60,7 +81,6 @@ function setupWebSocket(server) {
|
|
|
60
81
|
return wss;
|
|
61
82
|
}
|
|
62
83
|
|
|
63
|
-
// Broadcast an analytics event to all clients watching a specific site
|
|
64
84
|
function broadcastAnalytic(siteId, eventData) {
|
|
65
85
|
const clients = siteClients.get(siteId);
|
|
66
86
|
if (!clients || clients.size === 0) return;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# WAB-MCP Adapter
|
|
2
|
+
|
|
3
|
+
**MCP adapter for Web Agent Bridge** — exposes every capability of a WAB-enabled website as a set of [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) tools so that any MCP-compatible AI agent (Claude, GPT, Gemini, open-source LLMs, etc.) can discover, read, and interact with the site through a single, standardised interface.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
const { WABMCPAdapter } = require('wab-mcp-adapter');
|
|
9
|
+
|
|
10
|
+
const adapter = new WABMCPAdapter({
|
|
11
|
+
siteUrl: 'https://example.com',
|
|
12
|
+
transport: 'http', // 'http' | 'websocket' | 'direct'
|
|
13
|
+
apiKey: 'sk-optional', // optional API key
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// 1. Discover site capabilities
|
|
17
|
+
const doc = await adapter.discover();
|
|
18
|
+
|
|
19
|
+
// 2. Get MCP tool definitions for the AI agent
|
|
20
|
+
const tools = await adapter.getTools();
|
|
21
|
+
|
|
22
|
+
// 3. Execute a tool call
|
|
23
|
+
const result = await adapter.executeTool('wab_execute_action', {
|
|
24
|
+
name: 'signup',
|
|
25
|
+
params: { email: 'user@example.com' },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// 4. Clean up
|
|
29
|
+
adapter.close();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## API Reference
|
|
33
|
+
|
|
34
|
+
### `new WABMCPAdapter(options)`
|
|
35
|
+
|
|
36
|
+
| Option | Type | Default | Description |
|
|
37
|
+
|---|---|---|---|
|
|
38
|
+
| `siteUrl` | `string` | — | Target WAB site URL (required for `http` transport) |
|
|
39
|
+
| `siteId` | `string` | `null` | WAB site identifier |
|
|
40
|
+
| `apiKey` | `string` | `null` | API key for authenticated requests |
|
|
41
|
+
| `transport` | `string` | `'http'` | Transport type: `http`, `websocket`, or `direct` |
|
|
42
|
+
| `registryUrl` | `string` | `https://webagentbridge.com` | WAB fairness registry URL |
|
|
43
|
+
| `page` | `object` | — | Puppeteer/Playwright page (required for `direct`) |
|
|
44
|
+
| `wsUrl` | `string` | auto | WebSocket URL (required for `websocket` if no `siteUrl`) |
|
|
45
|
+
| `timeout` | `number` | `15000` | Request timeout in milliseconds |
|
|
46
|
+
|
|
47
|
+
### Methods
|
|
48
|
+
|
|
49
|
+
| Method | Returns | Description |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `discover(url?)` | `Promise<object>` | Fetch the WAB discovery document |
|
|
52
|
+
| `getTools()` | `Promise<object[]>` | Return MCP tool definitions (built-in + site-specific) |
|
|
53
|
+
| `executeTool(name, input)` | `Promise<object>` | Execute an MCP tool call |
|
|
54
|
+
| `close()` | `void` | Release transport resources |
|
|
55
|
+
|
|
56
|
+
## Built-in Tools
|
|
57
|
+
|
|
58
|
+
These tools are always available, regardless of which site actions are discovered:
|
|
59
|
+
|
|
60
|
+
| Tool | Description |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `wab_discover` | Fetch the WAB discovery document from a site |
|
|
63
|
+
| `wab_get_actions` | List available actions, optionally filtered by category |
|
|
64
|
+
| `wab_execute_action` | Execute any WAB action by name and params |
|
|
65
|
+
| `wab_read_content` | Read page element text by CSS selector |
|
|
66
|
+
| `wab_get_page_info` | Return page metadata and bridge configuration |
|
|
67
|
+
| `wab_fairness_search` | Search the WAB registry with fairness-weighted results |
|
|
68
|
+
| `wab_authenticate` | Authenticate with the site using an API key |
|
|
69
|
+
|
|
70
|
+
Site-specific actions are exposed as additional tools named `wab_<action_name>` and are generated automatically from the discovery document.
|
|
71
|
+
|
|
72
|
+
## Transport Options
|
|
73
|
+
|
|
74
|
+
| Transport | When to use | Requirements |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| **http** | Server-to-server or CLI tools calling a WAB site over REST | `siteUrl` |
|
|
77
|
+
| **websocket** | Real-time bidirectional communication with low latency | `wsUrl` or `siteUrl` |
|
|
78
|
+
| **direct** | In-browser automation with Puppeteer/Playwright | `page` object |
|
|
79
|
+
|
|
80
|
+
## Fairness Protocol
|
|
81
|
+
|
|
82
|
+
The WAB discovery registry uses a **fairness-weighted ranking** algorithm that prevents large, high-traffic sites from monopolising search results. When you call `wab_fairness_search`, the registry applies:
|
|
83
|
+
|
|
84
|
+
- **Inverse-popularity weighting** — smaller sites receive a ranking boost.
|
|
85
|
+
- **Recency bonus** — newly registered or recently updated sites surface sooner.
|
|
86
|
+
- **Category balancing** — results are distributed across categories to avoid domination by a single vertical.
|
|
87
|
+
|
|
88
|
+
This ensures a level playing field so every WAB-enabled site has equitable visibility to AI agents.
|
|
89
|
+
|
|
90
|
+
## Integration with Claude / MCP
|
|
91
|
+
|
|
92
|
+
Pass the tools returned by `getTools()` as the `tools` parameter when calling the Anthropic Messages API and route any `tool_use` blocks back through `executeTool`:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
const Anthropic = require('@anthropic-ai/sdk');
|
|
96
|
+
const { WABMCPAdapter } = require('wab-mcp-adapter');
|
|
97
|
+
|
|
98
|
+
const client = new Anthropic();
|
|
99
|
+
const adapter = new WABMCPAdapter({ siteUrl: 'https://shop.example.com' });
|
|
100
|
+
const tools = await adapter.getTools();
|
|
101
|
+
|
|
102
|
+
let messages = [{ role: 'user', content: 'Find the signup form and register me.' }];
|
|
103
|
+
|
|
104
|
+
while (true) {
|
|
105
|
+
const res = await client.messages.create({
|
|
106
|
+
model: 'claude-sonnet-4-20250514',
|
|
107
|
+
max_tokens: 1024,
|
|
108
|
+
tools,
|
|
109
|
+
messages,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (res.stop_reason === 'end_turn') break;
|
|
113
|
+
|
|
114
|
+
const toolBlocks = res.content.filter((b) => b.type === 'tool_use');
|
|
115
|
+
if (!toolBlocks.length) break;
|
|
116
|
+
|
|
117
|
+
messages.push({ role: 'assistant', content: res.content });
|
|
118
|
+
|
|
119
|
+
const toolResults = [];
|
|
120
|
+
for (const block of toolBlocks) {
|
|
121
|
+
const result = await adapter.executeTool(block.name, block.input);
|
|
122
|
+
toolResults.push({
|
|
123
|
+
type: 'tool_result',
|
|
124
|
+
tool_use_id: block.id,
|
|
125
|
+
content: JSON.stringify(result.content),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
messages.push({ role: 'user', content: toolResults });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
adapter.close();
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT — see [LICENSE](../LICENSE).
|