web-agent-bridge 3.8.1 → 3.9.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/sdk/index.js CHANGED
@@ -630,6 +630,8 @@ const { WABSafeMode, WABSafeModeError, POLICIES: WAB_SAFE_POLICIES } = require('
630
630
  const { WABGovernance, WABGovernanceError } = require('./governance');
631
631
  // Zero-Config Adoption — Auto-Discovery fallback for sites without /.well-known/wab.json
632
632
  const autoDiscovery = require('./auto-discovery');
633
+ // Agent Transaction Primitive (v3.9.0) — intent → authorization → execution → receipt.
634
+ const { ATPClient, ATPError } = require('./atp');
633
635
 
634
636
  module.exports = {
635
637
  WABAgent,
@@ -646,4 +648,6 @@ module.exports = {
646
648
  WABGovernanceError,
647
649
  autoDiscovery,
648
650
  discover: autoDiscovery.discover,
651
+ ATPClient,
652
+ ATPError,
649
653
  };
package/server/index.js CHANGED
@@ -305,6 +305,9 @@ const { wabTrustMiddleware } = require('./middleware/wab-trust');
305
305
  app.use(wabTrustMiddleware);
306
306
  app.use('/api/ring4', apiLimiter, ring4Router);
307
307
 
308
+ // ── Agent Transaction Primitive (ATP) v3.9.0 — intents · transactions · signed receipts ──
309
+ app.use('/api/atp', apiLimiter, require('./routes/transactions'));
310
+
308
311
  // ── WAB Commercial Foundations v3.8.0 (Partners · Trust Graph API · Governance SaaS · Enterprise Mesh) ──
309
312
  app.use('/api/partners', apiLimiter, require('./routes/partners'));
310
313
  app.use('/api/keys', apiLimiter, require('./routes/api-keys'));
@@ -0,0 +1,119 @@
1
+ -- ─────────────────────────────────────────────────────────────────────────────
2
+ -- Migration 020 — Agent Transaction Primitive (ATP) — v3.9.0
3
+ --
4
+ -- Promotes WAB from "discover + execute" to "trust + transaction" by
5
+ -- introducing intents, transactions, steps and signed receipts as
6
+ -- first-class primitives.
7
+ --
8
+ -- * atp_intents — signed human → agent authorization contracts
9
+ -- * atp_transactions — executions performed under an intent
10
+ -- * atp_steps — per-step ledger inside a transaction (retry/comp)
11
+ -- * atp_receipts — cryptographically signed proofs of outcome
12
+ -- * atp_nonces — single-use nonces to prevent replay
13
+ --
14
+ -- All state machines enforced by CHECK constraints so the DB itself
15
+ -- refuses illegal transitions.
16
+ -- ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ -- ── 1) Intents (the human → agent contract) ──────────────────────────────────
19
+ CREATE TABLE IF NOT EXISTS atp_intents (
20
+ id TEXT PRIMARY KEY, -- atp_int_<ulid>
21
+ user_id TEXT NOT NULL, -- principal (the human)
22
+ site_id TEXT, -- optional binding
23
+ agent_id TEXT, -- optional binding (the delegate)
24
+ purpose TEXT NOT NULL, -- short human-readable purpose
25
+ scope TEXT NOT NULL, -- JSON: { actions:[], domains:[], constraints:{} }
26
+ spend_cap_cents INTEGER NOT NULL DEFAULT 0, -- 0 = no cap (must be explicit)
27
+ spend_currency TEXT NOT NULL DEFAULT 'EUR',
28
+ spent_cents INTEGER NOT NULL DEFAULT 0, -- running total against the cap
29
+ max_executions INTEGER NOT NULL DEFAULT 1, -- how many transactions allowed
30
+ used_executions INTEGER NOT NULL DEFAULT 0,
31
+ expires_at TEXT NOT NULL, -- ISO-8601, hard cutoff
32
+ nonce TEXT NOT NULL UNIQUE, -- prevents replay across intents
33
+ status TEXT NOT NULL DEFAULT 'draft'
34
+ CHECK (status IN ('draft','authorized','consumed','revoked','expired')),
35
+ authorized_at TEXT,
36
+ authorized_by TEXT, -- user_id of the approver
37
+ user_signature TEXT, -- base64 Ed25519 sig of canonical body
38
+ revoked_at TEXT,
39
+ revoked_reason TEXT,
40
+ metadata TEXT NOT NULL DEFAULT '{}', -- JSON
41
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
42
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
43
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_atp_intents_user ON atp_intents(user_id, created_at DESC);
46
+ CREATE INDEX IF NOT EXISTS idx_atp_intents_status ON atp_intents(status, expires_at);
47
+ CREATE INDEX IF NOT EXISTS idx_atp_intents_site ON atp_intents(site_id);
48
+
49
+ -- ── 2) Transactions (executions under an intent) ─────────────────────────────
50
+ CREATE TABLE IF NOT EXISTS atp_transactions (
51
+ id TEXT PRIMARY KEY, -- atp_tx_<ulid>
52
+ intent_id TEXT NOT NULL,
53
+ site_id TEXT,
54
+ agent_id TEXT,
55
+ idempotency_key TEXT NOT NULL, -- caller-supplied, unique per intent
56
+ status TEXT NOT NULL DEFAULT 'pending'
57
+ CHECK (status IN ('pending','executing','executed','settled','failed','compensated')),
58
+ amount_cents INTEGER NOT NULL DEFAULT 0, -- net effect against intent.spend_cap
59
+ currency TEXT NOT NULL DEFAULT 'EUR',
60
+ summary TEXT, -- one-line outcome summary
61
+ error TEXT, -- failure reason if status='failed'
62
+ started_at TEXT,
63
+ completed_at TEXT,
64
+ settled_at TEXT,
65
+ compensated_at TEXT,
66
+ metadata TEXT NOT NULL DEFAULT '{}',
67
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
68
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
69
+ FOREIGN KEY (intent_id) REFERENCES atp_intents(id) ON DELETE CASCADE,
70
+ UNIQUE (intent_id, idempotency_key) -- the core safety guarantee
71
+ );
72
+ CREATE INDEX IF NOT EXISTS idx_atp_tx_intent ON atp_transactions(intent_id, created_at DESC);
73
+ CREATE INDEX IF NOT EXISTS idx_atp_tx_status ON atp_transactions(status, created_at DESC);
74
+ CREATE INDEX IF NOT EXISTS idx_atp_tx_site ON atp_transactions(site_id, created_at DESC);
75
+
76
+ -- ── 3) Steps (granular ledger for retry / compensation) ──────────────────────
77
+ CREATE TABLE IF NOT EXISTS atp_steps (
78
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
79
+ transaction_id TEXT NOT NULL,
80
+ seq INTEGER NOT NULL, -- step order, 1..N
81
+ action TEXT NOT NULL, -- WAB action name (e.g. "checkout.confirm")
82
+ state TEXT NOT NULL DEFAULT 'pending'
83
+ CHECK (state IN ('pending','running','succeeded','failed','skipped','compensated')),
84
+ before_snapshot TEXT, -- JSON: site state before step (optional)
85
+ after_snapshot TEXT, -- JSON: site state after step
86
+ evidence TEXT, -- JSON: arbitrary proof (DOM hash, http trace, …)
87
+ compensation TEXT, -- JSON: rollback action descriptor
88
+ attempts INTEGER NOT NULL DEFAULT 0,
89
+ last_error TEXT,
90
+ started_at TEXT,
91
+ ended_at TEXT,
92
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
93
+ FOREIGN KEY (transaction_id) REFERENCES atp_transactions(id) ON DELETE CASCADE,
94
+ UNIQUE (transaction_id, seq)
95
+ );
96
+ CREATE INDEX IF NOT EXISTS idx_atp_steps_tx ON atp_steps(transaction_id, seq);
97
+
98
+ -- ── 4) Receipts (signed proofs of outcome) ───────────────────────────────────
99
+ CREATE TABLE IF NOT EXISTS atp_receipts (
100
+ id TEXT PRIMARY KEY, -- atp_rcpt_<ulid>
101
+ transaction_id TEXT NOT NULL UNIQUE,
102
+ site_id TEXT, -- the signing party (if any)
103
+ algorithm TEXT NOT NULL DEFAULT 'ed25519',
104
+ key_id TEXT, -- fingerprint of signing key
105
+ canonical_body TEXT NOT NULL, -- the canonicalized JSON that was signed
106
+ signature TEXT NOT NULL, -- base64 Ed25519 signature
107
+ public_key TEXT, -- embedded pub key for offline verification
108
+ issued_at TEXT NOT NULL DEFAULT (datetime('now')),
109
+ FOREIGN KEY (transaction_id) REFERENCES atp_transactions(id) ON DELETE CASCADE
110
+ );
111
+ CREATE INDEX IF NOT EXISTS idx_atp_receipts_site ON atp_receipts(site_id, issued_at DESC);
112
+
113
+ -- ── 5) Nonces (single-use, replay protection) ────────────────────────────────
114
+ CREATE TABLE IF NOT EXISTS atp_nonces (
115
+ nonce TEXT PRIMARY KEY,
116
+ user_id TEXT NOT NULL,
117
+ consumed_at TEXT NOT NULL DEFAULT (datetime('now'))
118
+ );
119
+ CREATE INDEX IF NOT EXISTS idx_atp_nonces_user ON atp_nonces(user_id, consumed_at DESC);
@@ -0,0 +1,248 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * /api/atp — Agent Transaction Primitive REST surface.
5
+ *
6
+ * Authenticated endpoints (Bearer JWT, /me-scoped):
7
+ * POST /intents create draft intent
8
+ * GET /intents list my intents
9
+ * GET /intents/:id fetch one
10
+ * POST /intents/:id/authorize approve (single-use nonce burned)
11
+ * POST /intents/:id/revoke revoke
12
+ * POST /transactions begin tx under an authorized intent
13
+ * GET /transactions/:id fetch tx + steps
14
+ * POST /transactions/:id/steps append step
15
+ * POST /transactions/:id/transition move state machine
16
+ * POST /transactions/:id/compensate rollback
17
+ * POST /transactions/:id/receipt issue signed receipt
18
+ *
19
+ * Public endpoints (no auth — these ARE the trust primitive):
20
+ * GET /receipts/:id fetch a receipt (id only, no contents leak)
21
+ * POST /receipts/verify verify any signed receipt JSON offline-style
22
+ */
23
+
24
+ const express = require('express');
25
+ const router = express.Router();
26
+
27
+ const { authenticateToken } = require('../middleware/auth');
28
+ const transactions = require('../services/transactions');
29
+ const { db } = require('../models/db');
30
+
31
+ // ─── Tier gating ─────────────────────────────────────────────────────────────
32
+ // ATP is positioned at the open/paid boundary: intent creation and public
33
+ // verification are open (the protocol must spread), while throughput and
34
+ // advanced features are paid.
35
+ const ATP_INTENT_LIMITS = { free: 10, starter: 50, pro: 500, business: 5000, enterprise: 100000 };
36
+
37
+ function getUserTier(userId) {
38
+ try {
39
+ const row = db.prepare(`SELECT tier FROM sites WHERE user_id=? AND active=1 ORDER BY created_at ASC LIMIT 1`).get(userId);
40
+ return (row && row.tier) || 'free';
41
+ } catch { return 'free'; }
42
+ }
43
+
44
+ function checkDailyIntentQuota(userId) {
45
+ const tier = getUserTier(userId);
46
+ const cap = ATP_INTENT_LIMITS[tier] ?? ATP_INTENT_LIMITS.free;
47
+ const row = db.prepare(`
48
+ SELECT COUNT(*) AS n FROM atp_intents
49
+ WHERE user_id=? AND datetime(created_at) >= datetime('now','-1 day')
50
+ `).get(userId);
51
+ return { tier, used: row.n, cap, ok: row.n < cap };
52
+ }
53
+
54
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
55
+ function send(res, fn) {
56
+ try {
57
+ const out = fn();
58
+ res.json({ ok: true, ...(out && typeof out === 'object' ? { data: out } : {}) });
59
+ } catch (e) {
60
+ const code = e.statusCode || 500;
61
+ res.status(code).json({ ok: false, error: e.code || 'internal_error', message: e.message });
62
+ }
63
+ }
64
+
65
+ // ─── Intents ─────────────────────────────────────────────────────────────────
66
+ router.post('/intents', authenticateToken, express.json({ limit: '32kb' }), (req, res) => {
67
+ const q = checkDailyIntentQuota(req.user.id);
68
+ if (!q.ok) {
69
+ return res.status(429).json({
70
+ ok: false, error: 'quota_exceeded',
71
+ message: `Daily intent quota reached (${q.used}/${q.cap} on '${q.tier}' tier).`,
72
+ tier: q.tier, used: q.used, limit: q.cap,
73
+ upgrade_url: '/premium.html',
74
+ });
75
+ }
76
+ send(res, () => transactions.createIntent({
77
+ userId: req.user.id,
78
+ siteId: req.body.site_id || null,
79
+ agentId: req.body.agent_id || null,
80
+ purpose: req.body.purpose,
81
+ scope: req.body.scope,
82
+ spendCapCents: req.body.spend_cap_cents ?? 0,
83
+ spendCurrency: req.body.currency || 'EUR',
84
+ maxExecutions: req.body.max_executions ?? 1,
85
+ ttlSeconds: req.body.ttl_seconds ?? 3600,
86
+ metadata: req.body.metadata || {},
87
+ }));
88
+ });
89
+
90
+ router.get('/intents', authenticateToken, (req, res) => {
91
+ send(res, () => transactions.listIntentsForUser(req.user.id, {
92
+ limit: Math.min(parseInt(req.query.limit, 10) || 50, 200),
93
+ offset: parseInt(req.query.offset, 10) || 0,
94
+ }));
95
+ });
96
+
97
+ router.get('/intents/:id', authenticateToken, (req, res) => {
98
+ const intent = transactions.getIntent(req.params.id);
99
+ if (!intent) return res.status(404).json({ ok: false, error: 'not_found' });
100
+ if (intent.user_id !== req.user.id) return res.status(403).json({ ok: false, error: 'forbidden' });
101
+ res.json({ ok: true, data: intent });
102
+ });
103
+
104
+ router.post('/intents/:id/authorize', authenticateToken, (req, res) => {
105
+ send(res, () => transactions.authorizeIntent(req.params.id, { userId: req.user.id }));
106
+ });
107
+
108
+ router.post('/intents/:id/revoke', authenticateToken, express.json({ limit: '4kb' }), (req, res) => {
109
+ send(res, () => transactions.revokeIntent(req.params.id, { userId: req.user.id, reason: req.body.reason }));
110
+ });
111
+
112
+ // ─── Transactions ────────────────────────────────────────────────────────────
113
+ function loadIntentAuthorized(intentId, userId) {
114
+ const intent = transactions.getIntent(intentId);
115
+ if (!intent) { const e = new Error('intent not found'); e.statusCode = 404; e.code = 'not_found'; throw e; }
116
+ if (intent.user_id !== userId) { const e = new Error('forbidden'); e.statusCode = 403; e.code = 'forbidden'; throw e; }
117
+ return intent;
118
+ }
119
+
120
+ function loadTxOwned(txId, userId) {
121
+ const tx = transactions.getTransaction(txId);
122
+ if (!tx) { const e = new Error('transaction not found'); e.statusCode = 404; e.code = 'not_found'; throw e; }
123
+ const intent = transactions.getIntent(tx.intent_id);
124
+ if (!intent || intent.user_id !== userId) { const e = new Error('forbidden'); e.statusCode = 403; e.code = 'forbidden'; throw e; }
125
+ return { tx, intent };
126
+ }
127
+
128
+ router.post('/transactions', authenticateToken, express.json({ limit: '32kb' }), (req, res) => {
129
+ send(res, () => {
130
+ const intent = loadIntentAuthorized(req.body.intent_id, req.user.id);
131
+ const idem = req.headers['idempotency-key'] || req.body.idempotency_key;
132
+ return transactions.beginTransaction({
133
+ intentId: intent.id,
134
+ idempotencyKey: idem,
135
+ siteId: req.body.site_id,
136
+ agentId: req.body.agent_id,
137
+ amountCents: req.body.amount_cents ?? 0,
138
+ currency: req.body.currency || intent.spend_currency,
139
+ summary: req.body.summary,
140
+ metadata: req.body.metadata || {},
141
+ });
142
+ });
143
+ });
144
+
145
+ router.get('/transactions/:id', authenticateToken, (req, res) => {
146
+ send(res, () => {
147
+ const { tx } = loadTxOwned(req.params.id, req.user.id);
148
+ return { ...tx, steps: transactions.listSteps(tx.id) };
149
+ });
150
+ });
151
+
152
+ router.post('/transactions/:id/steps', authenticateToken, express.json({ limit: '256kb' }), (req, res) => {
153
+ send(res, () => {
154
+ loadTxOwned(req.params.id, req.user.id);
155
+ return transactions.appendStep(req.params.id, {
156
+ action: req.body.action,
157
+ evidence: req.body.evidence,
158
+ before: req.body.before,
159
+ after: req.body.after,
160
+ compensation: req.body.compensation,
161
+ });
162
+ });
163
+ });
164
+
165
+ const VALID_TARGETS = new Set(['executing','executed','settled','failed','compensated']);
166
+ router.post('/transactions/:id/transition', authenticateToken, express.json({ limit: '8kb' }), (req, res) => {
167
+ send(res, () => {
168
+ loadTxOwned(req.params.id, req.user.id);
169
+ const to = req.body.to;
170
+ if (!VALID_TARGETS.has(to)) {
171
+ const e = new Error(`invalid target state '${to}'`); e.statusCode = 400; e.code = 'invalid_request'; throw e;
172
+ }
173
+ return transactions.transitionTransaction(req.params.id, to, { error: req.body.error, summary: req.body.summary });
174
+ });
175
+ });
176
+
177
+ router.post('/transactions/:id/compensate', authenticateToken, express.json({ limit: '4kb' }), (req, res) => {
178
+ send(res, () => {
179
+ loadTxOwned(req.params.id, req.user.id);
180
+ return transactions.compensateTransaction(req.params.id, { reason: req.body.reason });
181
+ });
182
+ });
183
+
184
+ // Receipts — issuance requires Pro+ for persistent key binding;
185
+ // free tier gets ephemeral-key receipts (still verifiable, just not pinned).
186
+ router.post('/transactions/:id/receipt', authenticateToken, express.json({ limit: '4kb' }), (req, res) => {
187
+ send(res, () => {
188
+ const { tx } = loadTxOwned(req.params.id, req.user.id);
189
+ return transactions.issueReceipt(tx.id, { embedPublicKey: true });
190
+ });
191
+ });
192
+
193
+ // ─── Public verification (the trust primitive) ───────────────────────────────
194
+ const publicReceiptLimiter = (() => {
195
+ const buckets = new Map();
196
+ const WINDOW_MS = 60_000;
197
+ const MAX = 120;
198
+ return (req, res, next) => {
199
+ const key = req.ip;
200
+ const now = Date.now();
201
+ let b = buckets.get(key);
202
+ if (!b || (now - b.t) > WINDOW_MS) { b = { t: now, n: 0 }; buckets.set(key, b); }
203
+ b.n++;
204
+ if (b.n > MAX) return res.status(429).json({ ok: false, error: 'rate_limited' });
205
+ next();
206
+ };
207
+ })();
208
+
209
+ router.get('/receipts/:id', publicReceiptLimiter, (req, res) => {
210
+ const r = transactions.getReceipt(req.params.id);
211
+ if (!r) return res.status(404).json({ ok: false, error: 'not_found' });
212
+ res.json({ ok: true, data: { id: r.id, transaction_id: r.transaction_id, issued_at: r.issued_at, body: r.body } });
213
+ });
214
+
215
+ router.post('/receipts/verify', publicReceiptLimiter, express.json({ limit: '256kb' }), (req, res) => {
216
+ const input = req.body && (req.body.receipt || req.body);
217
+ let target = input;
218
+ // Allow lookup by id: { receipt_id: "..." } or { id: "..." } with no signature attached
219
+ const lookupId = typeof input === 'object' && input
220
+ ? (input.receipt_id || input.id)
221
+ : (typeof input === 'string' ? input : null);
222
+ if (lookupId && (typeof input !== 'object' || !input.signature)) {
223
+ const stored = transactions.getReceipt(lookupId);
224
+ if (!stored) return res.status(404).json({ ok: false, error: 'not_found' });
225
+ target = stored.body;
226
+ }
227
+ const r = transactions.verifyReceipt(target);
228
+ res.json({ ok: r.ok === true, verification: r });
229
+ });
230
+
231
+ router.get('/health', (req, res) => res.json({ ok: true, service: 'atp', version: '1.0.0' }));
232
+
233
+ // ─── Public transparency feed ─────────────────────────────────────────────
234
+ // WAB dogfoods ATP: every subscription payment processed via webagentbridge.com
235
+ // produces a publicly-verifiable Ed25519 receipt. This feed lists them.
236
+ router.get('/platform/receipts', publicReceiptLimiter, (req, res) => {
237
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20));
238
+ const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
239
+ const items = transactions.listPlatformReceipts({ limit, offset });
240
+ res.json({ ok: true, data: items, limit, offset });
241
+ });
242
+
243
+ router.get('/platform/stats', publicReceiptLimiter, (req, res) => {
244
+ const stats = transactions.getPlatformStats();
245
+ res.json({ ok: true, data: stats });
246
+ });
247
+
248
+ module.exports = router;
@@ -138,6 +138,25 @@ function handleWebhookEvent(event) {
138
138
  periodStart: new Date(invoice.period_start * 1000).toISOString(),
139
139
  periodEnd: new Date(invoice.period_end * 1000).toISOString()
140
140
  });
141
+ // ── WAB dogfooding: record this real money event as an ATP receipt ──
142
+ // Every dollar that flows into WAB is itself a publicly-verifiable
143
+ // Ed25519 receipt. Failure here MUST NOT block payment confirmation.
144
+ try {
145
+ const transactions = require('./transactions');
146
+ transactions.recordPlatformPayment({
147
+ userId: sub.user_id,
148
+ amountCents: invoice.amount_paid,
149
+ currency: (invoice.currency || 'USD').toUpperCase(),
150
+ tier: sub.tier,
151
+ externalRef: invoice.id || invoice.payment_intent,
152
+ description: `WAB ${sub.tier} subscription`,
153
+ periodStart: invoice.period_start ? new Date(invoice.period_start * 1000).toISOString() : null,
154
+ periodEnd: invoice.period_end ? new Date(invoice.period_end * 1000).toISOString() : null,
155
+ provider: 'stripe',
156
+ });
157
+ } catch (e) {
158
+ console.error('[atp] recordPlatformPayment failed (non-fatal):', e.message);
159
+ }
141
160
  }
142
161
  }
143
162
  break;