web-agent-bridge 2.9.0 → 3.2.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.
Files changed (59) hide show
  1. package/LICENSE +51 -0
  2. package/README.ar.md +79 -0
  3. package/README.md +104 -4
  4. package/package.json +2 -1
  5. package/public/.well-known/ai-plugin.json +28 -0
  6. package/public/agent-workspace.html +3 -1
  7. package/public/ai.html +5 -3
  8. package/public/api.html +412 -0
  9. package/public/browser.html +4 -2
  10. package/public/cookies.html +4 -2
  11. package/public/dashboard.html +5 -3
  12. package/public/demo.html +1770 -1
  13. package/public/docs.html +6 -4
  14. package/public/growth.html +463 -0
  15. package/public/index.html +982 -738
  16. package/public/llms-full.txt +52 -1
  17. package/public/llms.txt +39 -0
  18. package/public/login.html +6 -4
  19. package/public/premium-dashboard.html +7 -5
  20. package/public/premium.html +6 -4
  21. package/public/privacy.html +4 -2
  22. package/public/register.html +6 -4
  23. package/public/score.html +263 -0
  24. package/public/terms.html +4 -2
  25. package/sdk/index.js +7 -1
  26. package/sdk/package.json +12 -1
  27. package/server/index.js +427 -375
  28. package/server/middleware/rateLimits.js +3 -3
  29. package/server/migrations/006_growth_suite.sql +138 -0
  30. package/server/routes/agent-workspace.js +162 -0
  31. package/server/routes/demo-showcase.js +332 -0
  32. package/server/routes/discovery.js +18 -7
  33. package/server/routes/gateway.js +157 -0
  34. package/server/routes/growth.js +962 -0
  35. package/server/routes/runtime.js +204 -0
  36. package/server/routes/universal.js +9 -1
  37. package/server/routes/wab-api.js +16 -6
  38. package/server/runtime/container-worker.js +111 -0
  39. package/server/runtime/container.js +448 -0
  40. package/server/runtime/distributed-worker.js +362 -0
  41. package/server/runtime/index.js +21 -1
  42. package/server/runtime/queue.js +599 -0
  43. package/server/runtime/replay.js +431 -29
  44. package/server/runtime/scheduler.js +194 -55
  45. package/server/services/api-key-engine.js +261 -0
  46. package/server/services/lfd.js +22 -3
  47. package/server/services/modules/affiliate-intelligence.js +93 -0
  48. package/server/services/modules/agent-firewall.js +90 -0
  49. package/server/services/modules/bounty.js +89 -0
  50. package/server/services/modules/collective-bargaining.js +92 -0
  51. package/server/services/modules/dark-pattern.js +66 -0
  52. package/server/services/modules/gov-intelligence.js +45 -0
  53. package/server/services/modules/neural.js +55 -0
  54. package/server/services/modules/notary.js +49 -0
  55. package/server/services/modules/price-time-machine.js +86 -0
  56. package/server/services/modules/protocol.js +104 -0
  57. package/server/services/premium.js +1 -1
  58. package/server/services/price-intelligence.js +2 -1
  59. package/server/services/vision.js +2 -2
@@ -4,15 +4,19 @@
4
4
  * WAB Runtime - Task Scheduler
5
5
  *
6
6
  * Distributes and manages task execution with:
7
- * - Priority queue
7
+ * - External queue backend (SQLite/Redis/Memory)
8
+ * - Priority queue with persistent storage
8
9
  * - Retry logic with exponential backoff
9
10
  * - Timeout management
10
11
  * - Dependency resolution
11
12
  * - Concurrency control
13
+ * - Deterministic replay integration
14
+ * - Container isolation support
12
15
  */
13
16
 
14
17
  const crypto = require('crypto');
15
18
  const { bus } = require('./event-bus');
19
+ const { createQueue } = require('./queue');
16
20
 
17
21
  // Task states
18
22
  const TaskState = {
@@ -28,10 +32,13 @@ const TaskState = {
28
32
 
29
33
  class Scheduler {
30
34
  constructor(options = {}) {
31
- this._queue = []; // priority queue
35
+ this._queue = []; // fallback in-memory queue
36
+ this._externalQueue = null; // external queue backend
32
37
  this._running = new Map(); // taskId → task
33
38
  this._completed = new Map(); // taskId → result (limited buffer)
34
39
  this._handlers = new Map(); // task type → handler function
40
+ this._replayEngine = null; // optional replay integration
41
+ this._containerRunner = null; // optional container isolation
35
42
  this._maxConcurrent = options.maxConcurrent || 20;
36
43
  this._maxQueueSize = options.maxQueueSize || 1000;
37
44
  this._maxCompleted = options.maxCompleted || 500;
@@ -39,6 +46,31 @@ class Scheduler {
39
46
  this._defaultTimeout = options.defaultTimeout || 30000;
40
47
  this._processing = false;
41
48
  this._stats = { submitted: 0, completed: 0, failed: 0, retried: 0, cancelled: 0, timedOut: 0 };
49
+
50
+ // Initialize external queue
51
+ try {
52
+ this._externalQueue = createQueue('scheduler', {
53
+ maxRetries: this._defaultRetries,
54
+ processTimeout: this._defaultTimeout,
55
+ });
56
+ } catch {
57
+ // Fallback to in-memory
58
+ this._externalQueue = null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Attach the replay engine for deterministic recording
64
+ */
65
+ setReplayEngine(engine) {
66
+ this._replayEngine = engine;
67
+ }
68
+
69
+ /**
70
+ * Attach the container runner for process isolation
71
+ */
72
+ setContainerRunner(runner) {
73
+ this._containerRunner = runner;
42
74
  }
43
75
 
44
76
  /**
@@ -52,10 +84,6 @@ class Scheduler {
52
84
  * Submit a task to the scheduler
53
85
  */
54
86
  submit(task) {
55
- if (this._queue.length >= this._maxQueueSize) {
56
- throw new Error('Task queue is full');
57
- }
58
-
59
87
  const taskEntry = {
60
88
  id: task.id || `task_${crypto.randomBytes(16).toString('hex')}`,
61
89
  type: task.type || 'general',
@@ -72,6 +100,7 @@ class Scheduler {
72
100
  retriesLeft: task.retries !== undefined ? task.retries : this._defaultRetries,
73
101
  timeout: task.timeout || this._defaultTimeout,
74
102
  deadline: task.deadline || null,
103
+ isolate: task.isolate || false, // run in container if true
75
104
 
76
105
  // Dependencies
77
106
  dependsOn: task.dependsOn || [],
@@ -94,16 +123,31 @@ class Scheduler {
94
123
  checkpoints: [],
95
124
  };
96
125
 
97
- // Insert by priority (higher priority = earlier in queue)
98
- let inserted = false;
99
- for (let i = 0; i < this._queue.length; i++) {
100
- if (taskEntry.priority > this._queue[i].priority) {
101
- this._queue.splice(i, 0, taskEntry);
102
- inserted = true;
103
- break;
126
+ // Add to queue (external or in-memory)
127
+ if (this._externalQueue) {
128
+ this._externalQueue.enqueue({
129
+ type: taskEntry.type,
130
+ data: taskEntry,
131
+ priority: taskEntry.priority,
132
+ maxAttempts: taskEntry.retries + 1,
133
+ timeoutMs: taskEntry.timeout,
134
+ groupId: taskEntry.agentId || null,
135
+ });
136
+ } else {
137
+ if (this._queue.length >= this._maxQueueSize) {
138
+ throw new Error('Task queue is full');
104
139
  }
140
+ // Insert by priority (higher priority = earlier in queue)
141
+ let inserted = false;
142
+ for (let i = 0; i < this._queue.length; i++) {
143
+ if (taskEntry.priority > this._queue[i].priority) {
144
+ this._queue.splice(i, 0, taskEntry);
145
+ inserted = true;
146
+ break;
147
+ }
148
+ }
149
+ if (!inserted) this._queue.push(taskEntry);
105
150
  }
106
- if (!inserted) this._queue.push(taskEntry);
107
151
 
108
152
  this._stats.submitted++;
109
153
  bus.emit('task.queued', { taskId: taskEntry.id, type: taskEntry.type, priority: taskEntry.priority });
@@ -125,28 +169,43 @@ class Scheduler {
125
169
  if (this._processing) return;
126
170
  this._processing = true;
127
171
 
128
- while (this._queue.length > 0 && this._running.size < this._maxConcurrent) {
129
- const task = this._findNextReady();
130
- if (!task) break;
131
-
132
- // Remove from queue
133
- const idx = this._queue.indexOf(task);
134
- if (idx !== -1) this._queue.splice(idx, 1);
135
-
136
- // Check deadline
137
- if (task.deadline && Date.now() > task.deadline) {
138
- task.state = TaskState.CANCELLED;
139
- task.error = { code: 'DEADLINE_EXCEEDED', message: 'Task deadline has passed' };
140
- bus.emit('task.cancelled', { taskId: task.id, reason: 'deadline' });
141
- continue;
172
+ try {
173
+ while (this._running.size < this._maxConcurrent) {
174
+ let task = null;
175
+
176
+ if (this._externalQueue) {
177
+ // Dequeue from external queue
178
+ const item = this._externalQueue.dequeue();
179
+ if (!item) break;
180
+ task = item.data;
181
+ task._queueItemId = item.id;
182
+ } else {
183
+ // Dequeue from in-memory queue
184
+ if (this._queue.length === 0) break;
185
+ task = this._findNextReady();
186
+ if (!task) break;
187
+ const idx = this._queue.indexOf(task);
188
+ if (idx !== -1) this._queue.splice(idx, 1);
189
+ }
190
+
191
+ // Check deadline
192
+ if (task.deadline && Date.now() > task.deadline) {
193
+ task.state = TaskState.CANCELLED;
194
+ task.error = { code: 'DEADLINE_EXCEEDED', message: 'Task deadline has passed' };
195
+ if (this._externalQueue && task._queueItemId) {
196
+ this._externalQueue.fail(task._queueItemId, 'Deadline exceeded');
197
+ }
198
+ bus.emit('task.cancelled', { taskId: task.id, reason: 'deadline' });
199
+ continue;
200
+ }
201
+
202
+ // Execute
203
+ this._running.set(task.id, task);
204
+ this._executeTask(task); // fire and forget
142
205
  }
143
-
144
- // Execute
145
- this._running.set(task.id, task);
146
- this._executeTask(task); // fire and forget
206
+ } finally {
207
+ this._processing = false;
147
208
  }
148
-
149
- this._processing = false;
150
209
  }
151
210
 
152
211
  /**
@@ -174,39 +233,90 @@ class Scheduler {
174
233
 
175
234
  bus.emit('task.started', { taskId: task.id, attempt: task.attempt, type: task.type });
176
235
 
236
+ // Start deterministic recording
237
+ if (this._replayEngine) {
238
+ this._replayEngine.startRecording(task.id, {
239
+ type: task.type,
240
+ params: task.params,
241
+ objective: task.objective,
242
+ attempt: task.attempt,
243
+ });
244
+ }
245
+
177
246
  const handler = this._handlers.get(task.type);
178
247
  if (!handler) {
179
248
  task.state = TaskState.FAILED;
180
249
  task.error = { code: 'NO_HANDLER', message: `No handler for task type: ${task.type}` };
250
+ if (this._replayEngine) {
251
+ this._replayEngine.completeRecording(task.id, null, task.error);
252
+ }
181
253
  this._finishTask(task);
182
254
  return;
183
255
  }
184
256
 
185
257
  try {
186
- const result = await _withTimeout(
187
- handler(task.params, {
188
- taskId: task.id,
189
- agentId: task.agentId,
190
- siteId: task.siteId,
191
- traceId: task.traceId,
192
- steps: task.steps,
193
- attempt: task.attempt,
194
- reportProgress: (pct, step) => {
195
- task.progress = pct;
196
- if (step !== undefined) task.currentStep = step;
197
- bus.emit('task.progress', { taskId: task.id, progress: pct, step });
198
- },
199
- checkpoint: (data) => {
200
- task.checkpoints.push({ data, timestamp: Date.now(), step: task.currentStep });
201
- },
202
- }),
203
- task.timeout
204
- );
258
+ let result;
259
+
260
+ const ctx = {
261
+ taskId: task.id,
262
+ agentId: task.agentId,
263
+ siteId: task.siteId,
264
+ traceId: task.traceId,
265
+ steps: task.steps,
266
+ attempt: task.attempt,
267
+ reportProgress: (pct, step) => {
268
+ task.progress = pct;
269
+ if (step !== undefined) task.currentStep = step;
270
+ bus.emit('task.progress', { taskId: task.id, progress: pct, step });
271
+ },
272
+ checkpoint: (data) => {
273
+ task.checkpoints.push({ data, timestamp: Date.now(), step: task.currentStep });
274
+ if (this._replayEngine) {
275
+ this._replayEngine.saveCheckpoint(task.id, `step-${task.currentStep}`, data);
276
+ }
277
+ },
278
+ recordStep: (step) => {
279
+ if (this._replayEngine) {
280
+ this._replayEngine.recordStep(task.id, step);
281
+ }
282
+ },
283
+ recordSideEffect: (effect) => {
284
+ if (this._replayEngine) {
285
+ this._replayEngine.recordSideEffect(task.id, effect);
286
+ }
287
+ },
288
+ };
289
+
290
+ // Execute in container if isolation requested
291
+ if (task.isolate && this._containerRunner && typeof task.params === 'object' && task.params._code) {
292
+ const containerResult = await this._containerRunner.runInProcess(task.id, task.params._code, {
293
+ params: task.params,
294
+ timeout: task.timeout,
295
+ maxMemory: task.params._maxMemory || 256 * 1024 * 1024,
296
+ });
297
+ if (!containerResult.success) {
298
+ throw new Error(containerResult.error || 'Container execution failed');
299
+ }
300
+ result = containerResult.result;
301
+ } else {
302
+ result = await _withTimeout(handler(task.params, ctx), task.timeout);
303
+ }
205
304
 
206
305
  task.state = TaskState.COMPLETED;
207
306
  task.result = result;
208
307
  task.progress = 100;
209
308
  this._stats.completed++;
309
+
310
+ // Complete recording
311
+ if (this._replayEngine) {
312
+ this._replayEngine.completeRecording(task.id, result);
313
+ }
314
+
315
+ // Mark complete in external queue
316
+ if (this._externalQueue && task._queueItemId) {
317
+ this._externalQueue.complete(task._queueItemId, result);
318
+ }
319
+
210
320
  bus.emit('task.completed', { taskId: task.id, duration: Date.now() - task.startedAt });
211
321
  } catch (err) {
212
322
  const isTimeout = err.message.includes('timed out');
@@ -222,7 +332,18 @@ class Scheduler {
222
332
 
223
333
  setTimeout(() => {
224
334
  this._running.delete(task.id);
225
- this._queue.unshift(task); // Add back to front of queue
335
+ if (this._externalQueue) {
336
+ // Re-enqueue in external queue
337
+ this._externalQueue.enqueue({
338
+ type: task.type,
339
+ data: task,
340
+ priority: task.priority,
341
+ maxAttempts: task.retriesLeft + 1,
342
+ timeoutMs: task.timeout,
343
+ });
344
+ } else {
345
+ this._queue.unshift(task);
346
+ }
226
347
  this._processQueue();
227
348
  }, backoff);
228
349
  return;
@@ -231,6 +352,17 @@ class Scheduler {
231
352
  task.state = TaskState.FAILED;
232
353
  task.error = { code: isTimeout ? 'TIMEOUT' : 'EXECUTION_ERROR', message: err.message };
233
354
  this._stats.failed++;
355
+
356
+ // Complete recording with error
357
+ if (this._replayEngine) {
358
+ this._replayEngine.completeRecording(task.id, null, err);
359
+ }
360
+
361
+ // Mark failed in external queue
362
+ if (this._externalQueue && task._queueItemId) {
363
+ this._externalQueue.fail(task._queueItemId, err.message);
364
+ }
365
+
234
366
  bus.emit('task.failed', { taskId: task.id, error: err.message, attempts: task.attempt });
235
367
  }
236
368
 
@@ -347,11 +479,18 @@ class Scheduler {
347
479
  * Get scheduler stats
348
480
  */
349
481
  getStats() {
482
+ const queueSize = this._externalQueue
483
+ ? this._externalQueue.size()
484
+ : this._queue.length;
485
+
350
486
  return {
351
487
  ...this._stats,
352
- queueSize: this._queue.length,
488
+ queueSize,
489
+ queueBackend: this._externalQueue ? this._externalQueue.constructor.name : 'memory',
353
490
  runningCount: this._running.size,
354
491
  completedBufferSize: this._completed.size,
492
+ replayEnabled: !!this._replayEngine,
493
+ containerEnabled: !!this._containerRunner,
355
494
  };
356
495
  }
357
496
 
@@ -0,0 +1,261 @@
1
+ /**
2
+ * WAB API Key Engine
3
+ * Authentication, authorization, rate limiting, and quota management
4
+ * for all WAB advanced modules.
5
+ *
6
+ * Powered by WAB — Web Agent Bridge
7
+ * https://www.webagentbridge.com
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const crypto = require('crypto');
13
+
14
+ // ─── Plan Definitions ─────────────────────────────────────────────────────────
15
+ const PLANS = {
16
+ FREE: {
17
+ name: 'Free',
18
+ price_usd: 0,
19
+ requests_per_day: 100,
20
+ requests_per_minute: 10,
21
+ modules_allowed: ['dark-pattern', 'price', 'protocol', 'bounty'],
22
+ features: {
23
+ agent_firewall: false, notary: false, dark_pattern: true,
24
+ collective_bargaining: false, gov_intelligence: false,
25
+ price_time_machine: true, neural: false, protocol: true,
26
+ bounty: true, affiliate: false,
27
+ },
28
+ support: 'community',
29
+ data_retention_days: 7,
30
+ },
31
+ PRO: {
32
+ name: 'Pro',
33
+ price_usd: 29,
34
+ requests_per_day: 10000,
35
+ requests_per_minute: 100,
36
+ modules_allowed: ['agent-firewall', 'dark-pattern', 'neural', 'bounty', 'affiliate', 'protocol', 'price', 'bargaining'],
37
+ features: {
38
+ agent_firewall: true, notary: false, dark_pattern: true,
39
+ collective_bargaining: true, gov_intelligence: false,
40
+ price_time_machine: true, neural: true, protocol: true,
41
+ bounty: true, affiliate: true,
42
+ },
43
+ support: 'email',
44
+ data_retention_days: 90,
45
+ },
46
+ BUSINESS: {
47
+ name: 'Business',
48
+ price_usd: 149,
49
+ requests_per_day: 100000,
50
+ requests_per_minute: 500,
51
+ modules_allowed: ['all'],
52
+ features: {
53
+ agent_firewall: true, notary: true, dark_pattern: true,
54
+ collective_bargaining: true, gov_intelligence: true,
55
+ price_time_machine: true, neural: true, protocol: true,
56
+ bounty: true, affiliate: true,
57
+ },
58
+ support: 'priority',
59
+ data_retention_days: 365,
60
+ },
61
+ ENTERPRISE: {
62
+ name: 'Enterprise',
63
+ price_usd: null,
64
+ requests_per_day: Infinity,
65
+ requests_per_minute: Infinity,
66
+ modules_allowed: ['all'],
67
+ features: {
68
+ agent_firewall: true, notary: true, dark_pattern: true,
69
+ collective_bargaining: true, gov_intelligence: true,
70
+ price_time_machine: true, neural: true, protocol: true,
71
+ bounty: true, affiliate: true,
72
+ },
73
+ support: 'dedicated',
74
+ data_retention_days: Infinity,
75
+ custom_sla: true,
76
+ on_premise: true,
77
+ },
78
+ INTERNAL: {
79
+ name: 'Internal',
80
+ price_usd: 0,
81
+ requests_per_day: Infinity,
82
+ requests_per_minute: Infinity,
83
+ modules_allowed: ['all'],
84
+ features: Object.fromEntries(
85
+ ['agent_firewall','notary','dark_pattern','collective_bargaining','gov_intelligence',
86
+ 'price_time_machine','neural','protocol','bounty','affiliate'].map(k => [k, true])
87
+ ),
88
+ support: 'internal',
89
+ data_retention_days: Infinity,
90
+ },
91
+ };
92
+
93
+ const keyStore = new Map();
94
+ const usageStore = new Map();
95
+ const rateLimitStore = new Map();
96
+
97
+ class WABKeyEngine {
98
+ constructor() {
99
+ this.internalKey = this._seedInternalKeys();
100
+ }
101
+
102
+ _seedInternalKeys() {
103
+ const internalKey = 'wab_internal_' + crypto.randomBytes(16).toString('hex');
104
+ keyStore.set(internalKey, {
105
+ key: internalKey, key_id: 'kid_internal_001', plan: 'INTERNAL',
106
+ owner: 'WAB Core Team', email: 'dev@webagentbridge.com',
107
+ environment: 'internal', created_at: new Date().toISOString(),
108
+ last_used: null, active: true, scopes: ['*'],
109
+ });
110
+ return internalKey;
111
+ }
112
+
113
+ generateKey(options = {}) {
114
+ const { plan = 'FREE', owner, email, environment = 'live', scopes = [], metadata = {} } = options;
115
+ if (!PLANS[plan]) throw new Error(`Invalid plan: ${plan}`);
116
+ if (!owner) throw new Error('owner is required');
117
+ if (!email) throw new Error('email is required');
118
+
119
+ const planPrefix = plan.toLowerCase().substring(0, 3);
120
+ const randomPart = crypto.randomBytes(20).toString('hex');
121
+ const apiKey = `wab_${environment}_${planPrefix}_${randomPart}`;
122
+ const keyId = 'kid_' + crypto.randomBytes(8).toString('hex');
123
+ const webhookSecret = 'whsec_' + crypto.randomBytes(24).toString('hex');
124
+
125
+ const keyRecord = {
126
+ key: apiKey, key_id: keyId, plan, plan_details: PLANS[plan],
127
+ owner, email, environment,
128
+ created_at: new Date().toISOString(),
129
+ expires_at: plan === 'FREE' ? new Date(Date.now() + 365 * 86400000).toISOString() : null,
130
+ last_used: null, active: true,
131
+ scopes: scopes.length > 0 ? scopes : this._defaultScopes(plan),
132
+ webhook_secret: webhookSecret, metadata, total_requests: 0,
133
+ };
134
+
135
+ keyStore.set(apiKey, keyRecord);
136
+ usageStore.set(apiKey, { today: 0, this_month: 0, total: 0, by_module: {}, by_day: {}, last_reset: new Date().toDateString() });
137
+
138
+ return {
139
+ api_key: apiKey, key_id: keyId, webhook_secret: webhookSecret, plan,
140
+ plan_details: { name: PLANS[plan].name, requests_per_day: PLANS[plan].requests_per_day, requests_per_minute: PLANS[plan].requests_per_minute, modules_allowed: PLANS[plan].modules_allowed },
141
+ created_at: keyRecord.created_at, expires_at: keyRecord.expires_at,
142
+ };
143
+ }
144
+
145
+ validate(apiKey, module = null) {
146
+ if (!apiKey) return { valid: false, error: 'API key is required', code: 'MISSING_KEY' };
147
+ const record = keyStore.get(apiKey);
148
+ if (!record) return { valid: false, error: 'Invalid API key', code: 'INVALID_KEY' };
149
+ if (!record.active) return { valid: false, error: 'API key has been revoked', code: 'REVOKED_KEY' };
150
+ if (record.expires_at && new Date(record.expires_at) < new Date()) {
151
+ return { valid: false, error: 'API key has expired', code: 'EXPIRED_KEY' };
152
+ }
153
+
154
+ if (module) {
155
+ const plan = PLANS[record.plan];
156
+ const hasAccess = plan.modules_allowed.includes('all') || plan.modules_allowed.includes(module);
157
+ if (!hasAccess) {
158
+ return { valid: false, error: `Module '${module}' not available on ${plan.name} plan`, code: 'INSUFFICIENT_PLAN',
159
+ upgrade_url: 'https://www.webagentbridge.com/#pricing', current_plan: plan.name, required_plan: this._getMinPlanForModule(module) };
160
+ }
161
+ }
162
+
163
+ const rateCheck = this._checkRateLimit(apiKey, record.plan);
164
+ if (!rateCheck.allowed) {
165
+ return { valid: false, error: 'Rate limit exceeded', code: 'RATE_LIMIT_EXCEEDED', retry_after_seconds: rateCheck.retry_after, limit: rateCheck.limit };
166
+ }
167
+
168
+ const usage = usageStore.get(apiKey);
169
+ this._resetDailyIfNeeded(apiKey, usage);
170
+ const plan = PLANS[record.plan];
171
+ if (usage.today >= plan.requests_per_day) {
172
+ return { valid: false, error: 'Daily quota exceeded', code: 'QUOTA_EXCEEDED', used: usage.today, limit: plan.requests_per_day, upgrade_url: 'https://www.webagentbridge.com/#pricing' };
173
+ }
174
+
175
+ this._recordUsage(apiKey, module);
176
+ return { valid: true, key_id: record.key_id, plan: record.plan, plan_name: plan.name, owner: record.owner, environment: record.environment, features: plan.features,
177
+ usage: { today: usage.today + 1, limit_today: plan.requests_per_day, remaining_today: plan.requests_per_day - usage.today - 1 } };
178
+ }
179
+
180
+ revoke(apiKey, reason = 'user_request') {
181
+ const record = keyStore.get(apiKey);
182
+ if (!record) return { success: false, error: 'Key not found' };
183
+ record.active = false; record.revoked_at = new Date().toISOString(); record.revoke_reason = reason;
184
+ return { success: true, message: 'Key revoked', revoked_at: record.revoked_at };
185
+ }
186
+
187
+ rotate(oldKey) {
188
+ const record = keyStore.get(oldKey);
189
+ if (!record) return { success: false, error: 'Key not found' };
190
+ this.revoke(oldKey, 'rotation');
191
+ return { success: true, ...this.generateKey({ plan: record.plan, owner: record.owner, email: record.email, environment: record.environment, metadata: { ...record.metadata, rotated_from: record.key_id } }) };
192
+ }
193
+
194
+ getUsage(apiKey) {
195
+ const record = keyStore.get(apiKey);
196
+ if (!record) return { error: 'Key not found' };
197
+ const usage = usageStore.get(apiKey) || {};
198
+ this._resetDailyIfNeeded(apiKey, usage);
199
+ const plan = PLANS[record.plan];
200
+ return { key_id: record.key_id, plan: record.plan, plan_name: plan.name, today: usage.today, this_month: usage.this_month, total: usage.total,
201
+ limit_per_day: plan.requests_per_day, limit_per_minute: plan.requests_per_minute, remaining_today: Math.max(0, plan.requests_per_day - usage.today),
202
+ by_module: usage.by_module, last_used: record.last_used, created_at: record.created_at };
203
+ }
204
+
205
+ listKeys(adminKey) {
206
+ const adminRecord = keyStore.get(adminKey);
207
+ if (!adminRecord || adminRecord.plan !== 'INTERNAL') return { error: 'Admin access required' };
208
+ return { total: keyStore.size, keys: Array.from(keyStore.values()).map(r => ({
209
+ key_id: r.key_id, plan: r.plan, owner: r.owner, email: r.email, environment: r.environment, active: r.active,
210
+ created_at: r.created_at, last_used: r.last_used, total_requests: r.total_requests || 0 })) };
211
+ }
212
+
213
+ getPlans() {
214
+ return Object.entries(PLANS).filter(([k]) => k !== 'INTERNAL').map(([key, plan]) => ({
215
+ id: key, name: plan.name, price_usd: plan.price_usd, requests_per_day: plan.requests_per_day,
216
+ requests_per_minute: plan.requests_per_minute, features: plan.features, support: plan.support }));
217
+ }
218
+
219
+ _checkRateLimit(apiKey, plan) {
220
+ const limit = PLANS[plan].requests_per_minute;
221
+ if (limit === Infinity) return { allowed: true };
222
+ const now = Date.now(); const window = 60000;
223
+ const rl = rateLimitStore.get(apiKey) || { count: 0, windowStart: now };
224
+ if (now - rl.windowStart > window) { rl.count = 0; rl.windowStart = now; }
225
+ if (rl.count >= limit) { return { allowed: false, retry_after: Math.ceil((rl.windowStart + window - now) / 1000), limit }; }
226
+ rl.count++; rateLimitStore.set(apiKey, rl);
227
+ return { allowed: true };
228
+ }
229
+
230
+ _recordUsage(apiKey, module) {
231
+ const record = keyStore.get(apiKey); const usage = usageStore.get(apiKey);
232
+ const today = new Date().toISOString().split('T')[0];
233
+ usage.today++; usage.this_month++; usage.total++;
234
+ if (module) usage.by_module[module] = (usage.by_module[module] || 0) + 1;
235
+ usage.by_day[today] = (usage.by_day[today] || 0) + 1;
236
+ record.last_used = new Date().toISOString();
237
+ record.total_requests = (record.total_requests || 0) + 1;
238
+ }
239
+
240
+ _resetDailyIfNeeded(apiKey, usage) {
241
+ const today = new Date().toDateString();
242
+ if (usage.last_reset !== today) { usage.today = 0; usage.last_reset = today; usageStore.set(apiKey, usage); }
243
+ }
244
+
245
+ _defaultScopes(plan) {
246
+ if (plan === 'FREE') return ['read'];
247
+ if (plan === 'PRO') return ['read', 'write'];
248
+ return ['read', 'write', 'admin'];
249
+ }
250
+
251
+ _getMinPlanForModule(module) {
252
+ const map = { 'agent-firewall': 'PRO', 'notary': 'BUSINESS', 'dark-pattern': 'FREE', 'bargaining': 'PRO', 'gov': 'BUSINESS', 'price': 'FREE', 'neural': 'PRO', 'protocol': 'FREE', 'bounty': 'FREE', 'affiliate': 'PRO' };
253
+ return map[module] || 'PRO';
254
+ }
255
+
256
+ _nextMidnight() {
257
+ const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(0, 0, 0, 0); return d.toISOString();
258
+ }
259
+ }
260
+
261
+ module.exports = { WABKeyEngine, PLANS };
@@ -277,11 +277,10 @@ class RecipeExecutor {
277
277
  errors: [],
278
278
  };
279
279
 
280
- // Variable substitution in steps
280
+ // Variable substitution in steps — substitute in all string fields
281
281
  if (Object.keys(execution.variables).length > 0) {
282
282
  for (const step of execution.steps) {
283
- if (step.value) step.value = this._substituteVars(step.value, execution.variables);
284
- if (step.url) step.url = this._substituteVars(step.url, execution.variables);
283
+ this._substituteStepVars(step, execution.variables);
285
284
  }
286
285
  }
287
286
 
@@ -379,6 +378,23 @@ class RecipeExecutor {
379
378
  return str.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] !== undefined ? String(vars[key]) : `{{${key}}}`);
380
379
  }
381
380
 
381
+ _substituteStepVars(step, vars) {
382
+ if (step.value) step.value = this._substituteVars(step.value, vars);
383
+ if (step.url) step.url = this._substituteVars(step.url, vars);
384
+ if (step.selector) step.selector = this._substituteVars(step.selector, vars);
385
+ if (step.description) step.description = this._substituteVars(step.description, vars);
386
+ if (step.fallback) {
387
+ if (step.fallback.text) step.fallback.text = this._substituteVars(step.fallback.text, vars);
388
+ if (step.fallback.xpath) step.fallback.xpath = this._substituteVars(step.fallback.xpath, vars);
389
+ if (step.fallback.ariaLabel) step.fallback.ariaLabel = this._substituteVars(step.fallback.ariaLabel, vars);
390
+ }
391
+ if (step.options && typeof step.options === 'object') {
392
+ for (const k of Object.keys(step.options)) {
393
+ if (typeof step.options[k] === 'string') step.options[k] = this._substituteVars(step.options[k], vars);
394
+ }
395
+ }
396
+ }
397
+
382
398
  getStats() {
383
399
  const execs = [...this.executions.values()];
384
400
  return {
@@ -447,6 +463,9 @@ class LfdEngine {
447
463
  stopRecording(sessionId) {
448
464
  const session = this.sessions.get(sessionId);
449
465
  if (!session) throw new Error('Recording session not found');
466
+ if (session.status !== 'recording' && session.status !== 'paused') {
467
+ throw new Error(`Cannot stop recording in state: ${session.status}`);
468
+ }
450
469
  session.complete();
451
470
 
452
471
  // Auto-convert to recipe