web-agent-bridge 3.8.1 → 3.9.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/README.ar.md +2 -0
- package/README.md +37 -3
- package/package.json +2 -2
- package/public/atp.html +171 -0
- package/sdk/atp.js +103 -0
- package/sdk/index.js +4 -0
- package/server/index.js +3 -0
- package/server/migrations/020_agent_transaction_primitive.sql +119 -0
- package/server/routes/transactions.js +233 -0
- package/server/services/transactions.js +525 -0
package/README.ar.md
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
**البروتوكول والمنصة المفتوحة للتفاعل بين الذكاء الاصطناعي والويب — واجهة أوامر موحّدة، متصفح سيادي، درع هاتف، اكتشاف عبر DNS، شبكة وكلاء، وبوابة API موحّدة لتفاعل آمن بين الذكاء الاصطناعي والمواقع.**
|
|
17
17
|
|
|
18
|
+
> **جديد في الإصدار 3.9 — ATP (المُكوِّن الأساسي لمعاملات الوكلاء):** عقود نوايا موقَّعة، معاملات لا تتكرر (idempotent)، إيصالات Ed25519 قابلة للتحقق علنياً بدون مصادقة، وآلية تعويض صريحة. هذا هو **طبقة الثقة + المعاملات** التي تنقُص التجارة الوكيلة. [راجع المواصفة §21](docs/SPEC.md#21-agent-transaction-primitive-atp--v390) · [صفحة ATP العامة](public/atp.html).
|
|
19
|
+
|
|
18
20
|
🌐 **الموقع الرسمي:** [https://webagentbridge.com](https://webagentbridge.com) — جرّب مساحة عمل الوكيل الذكي ولوحات التحكم والعديد من الميزات الأخرى مباشرة.
|
|
19
21
|
|
|
20
22
|
يتيح WAB لأصحاب المواقع إضافة سكريبت يكشف واجهة `window.AICommands` لوكلاء الذكاء الاصطناعي. بدلاً من تحليل شيفرة HTML المعقدة، يقرأ الوكيل قائمة الإجراءات المتاحة وينفذها بدقة وأمان.
|
package/README.md
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
<img src="https://raw.githubusercontent.com/abokenan444/web-agent-bridge/master/public/images/wab-logo-large.png" alt="Web Agent Bridge Logo" width="180" />
|
|
4
4
|
|
|
5
5
|
<h1>Web Agent Bridge (WAB)</h1>
|
|
6
|
-
<p><b>The
|
|
7
|
-
<p><i>
|
|
6
|
+
<p><b>The trust + transaction layer for agentic commerce.</b></p>
|
|
7
|
+
<p><i>Signed intent contracts · idempotent transactions · Ed25519-verifiable receipts · explicit compensation.</i></p>
|
|
8
|
+
<p><i>robots.txt told bots what NOT to do. WAB tells AI agents what they CAN do — and proves what they did.</i></p>
|
|
8
9
|
|
|
9
10
|
[](https://www.npmjs.com/package/web-agent-bridge)
|
|
10
11
|
[](LICENSE)
|
|
11
|
-
[](tests)
|
|
13
|
+
[](docs/SPEC.md#21-agent-transaction-primitive-atp--v390)
|
|
12
14
|
[](https://discord.gg/NnbpJYEF)
|
|
13
15
|
|
|
14
16
|
<br />
|
|
@@ -31,6 +33,38 @@ AI agents today guess their way through the web — DOM scraping, brittle select
|
|
|
31
33
|
|
|
32
34
|
---
|
|
33
35
|
|
|
36
|
+
## ATP — Agent Transaction Primitive *(new in v3.9)*
|
|
37
|
+
|
|
38
|
+
WAB v3.9 introduces the **Agent Transaction Primitive (ATP)** — the missing trust + transaction layer for agentic commerce.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
Intent → Authorize → Transact → Receipt → (Compensate)
|
|
42
|
+
contract single-use idempotent Ed25519- explicit
|
|
43
|
+
+ scope nonce burn UNIQUE key signed rollback
|
|
44
|
+
+ spend cap JSON
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- **Intent contracts** — the user's signed authorization (scope, spend cap, expiry, single-use nonce).
|
|
48
|
+
- **Idempotent execution** — `UNIQUE (intent_id, idempotency_key)`: retries can never double-execute.
|
|
49
|
+
- **Signed receipts** — Ed25519 over canonical JSON; verifiable via the **public** `/api/atp/receipts/verify` endpoint with zero auth.
|
|
50
|
+
- **Compensation** — explicit rollback that decrements the intent's spend counter.
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
const { ATPClient } = require('web-agent-bridge/sdk');
|
|
54
|
+
const atp = new ATPClient({ baseUrl: 'https://api.webagentbridge.com', token: USER_JWT });
|
|
55
|
+
|
|
56
|
+
const intent = await atp.createIntent({ purpose: 'buy 1 widget', scope: { actions: ['cart.add','checkout'] }, max_spend_cents: 5000, currency: 'EUR' });
|
|
57
|
+
await atp.authorizeIntent(intent.id);
|
|
58
|
+
const tx = await atp.beginTransaction({ intent_id: intent.id, idempotency_key: 'order-42', amount_cents: 4200 });
|
|
59
|
+
await atp.transition(tx.id, 'executing'); await atp.transition(tx.id, 'executed'); await atp.transition(tx.id, 'settled');
|
|
60
|
+
const r = await atp.issueReceipt(tx.id); // signed
|
|
61
|
+
const v = await atp.verifyReceipt({ receiptId: r.receipt_id }); // public, no auth
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Full spec: [`docs/SPEC.md` §21](docs/SPEC.md#21-agent-transaction-primitive-atp--v390) · Public docs page: [`/atp.html`](public/atp.html).
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
34
68
|
## Quick start (60 seconds)
|
|
35
69
|
|
|
36
70
|
```bash
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "web-agent-bridge",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.9.0",
|
|
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",
|
|
7
7
|
"bin": {
|
package/public/atp.html
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
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" />
|
|
6
|
+
<title>Agent Transaction Primitive (ATP) · Web Agent Bridge</title>
|
|
7
|
+
<meta name="description" content="The first-class primitive for trusted agent execution: signed intent contracts, idempotent transactions, and cryptographically verifiable receipts." />
|
|
8
|
+
<link rel="stylesheet" href="/css/main.css" />
|
|
9
|
+
<style>
|
|
10
|
+
:root { --bg:#0a0e14; --fg:#e6edf3; --muted:#8b949e; --accent:#7ee787; --line:#30363d; --card:#161b22; }
|
|
11
|
+
body { background:var(--bg); color:var(--fg); font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; margin:0; padding:0; }
|
|
12
|
+
main { max-width:980px; margin:0 auto; padding:48px 24px 96px; }
|
|
13
|
+
h1 { font-size:2.5rem; margin:0 0 8px; letter-spacing:-0.02em; }
|
|
14
|
+
h2 { font-size:1.5rem; margin:48px 0 12px; border-bottom:1px solid var(--line); padding-bottom:8px; }
|
|
15
|
+
h3 { font-size:1.1rem; margin:24px 0 8px; color:var(--accent); }
|
|
16
|
+
p.lede { font-size:1.2rem; color:var(--muted); margin:0 0 32px; max-width:720px; }
|
|
17
|
+
.badge { display:inline-block; background:#1f6feb22; color:#79c0ff; border:1px solid #1f6feb55; padding:2px 10px; border-radius:99px; font-size:0.85rem; font-weight:600; }
|
|
18
|
+
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:16px; margin:24px 0; }
|
|
19
|
+
.card { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:20px; }
|
|
20
|
+
.card h3 { margin-top:0; color:var(--accent); }
|
|
21
|
+
.card p { color:var(--muted); font-size:0.95rem; margin:0; }
|
|
22
|
+
pre { background:#010409; border:1px solid var(--line); border-radius:6px; padding:16px; overflow-x:auto; font-size:0.85rem; color:#c9d1d9; }
|
|
23
|
+
code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }
|
|
24
|
+
.lifecycle { display:flex; gap:8px; align-items:center; margin:24px 0; flex-wrap:wrap; }
|
|
25
|
+
.lifecycle .step { flex:1; min-width:140px; background:var(--card); border:1px solid var(--line); padding:16px; border-radius:6px; text-align:center; }
|
|
26
|
+
.lifecycle .step b { display:block; font-size:1.1rem; color:var(--accent); margin-bottom:4px; }
|
|
27
|
+
.lifecycle .arrow { color:var(--muted); font-size:1.5rem; }
|
|
28
|
+
table { width:100%; border-collapse:collapse; margin:16px 0; }
|
|
29
|
+
th, td { text-align:left; padding:10px 12px; border-bottom:1px solid var(--line); font-size:0.95rem; }
|
|
30
|
+
th { color:var(--accent); font-weight:600; }
|
|
31
|
+
.tier { display:inline-block; padding:1px 8px; border-radius:4px; font-size:0.8rem; font-weight:600; }
|
|
32
|
+
.tier-free { background:#3fb95022; color:#7ee787; border:1px solid #3fb95055; }
|
|
33
|
+
.tier-pro { background:#1f6feb22; color:#79c0ff; border:1px solid #1f6feb55; }
|
|
34
|
+
.tier-business { background:#bf8b3022; color:#e3b341; border:1px solid #bf8b3055; }
|
|
35
|
+
.tier-enterprise { background:#a371f722; color:#d2a8ff; border:1px solid #a371f755; }
|
|
36
|
+
nav.top { position:sticky; top:0; background:rgba(10,14,20,0.9); backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:12px 24px; }
|
|
37
|
+
nav.top a { color:var(--fg); text-decoration:none; margin-right:20px; font-weight:500; }
|
|
38
|
+
nav.top a:hover { color:var(--accent); }
|
|
39
|
+
</style>
|
|
40
|
+
</head>
|
|
41
|
+
<body>
|
|
42
|
+
<nav class="top">
|
|
43
|
+
<a href="/">WAB</a>
|
|
44
|
+
<a href="/atp.html"><b>ATP</b></a>
|
|
45
|
+
<a href="/docs.html">Docs</a>
|
|
46
|
+
<a href="/premium.html">Pricing</a>
|
|
47
|
+
<a href="/login.html">Login</a>
|
|
48
|
+
</nav>
|
|
49
|
+
<main>
|
|
50
|
+
<span class="badge">v3.9.0 · the keystone primitive</span>
|
|
51
|
+
<h1>Agent Transaction Primitive</h1>
|
|
52
|
+
<p class="lede">
|
|
53
|
+
WAB is no longer just a discovery and execution protocol — it is the
|
|
54
|
+
<b>trust + transaction layer</b> for agentic commerce. ATP makes four
|
|
55
|
+
guarantees first-class: a signed intent contract from the human,
|
|
56
|
+
idempotent execution, a cryptographically verifiable receipt, and
|
|
57
|
+
explicit compensation.
|
|
58
|
+
</p>
|
|
59
|
+
|
|
60
|
+
<h2>The lifecycle</h2>
|
|
61
|
+
<div class="lifecycle">
|
|
62
|
+
<div class="step"><b>1 · Intent</b>The user declares scope, spend cap, expiry. Single-use nonce.</div>
|
|
63
|
+
<div class="arrow">→</div>
|
|
64
|
+
<div class="step"><b>2 · Authorize</b>The user confirms. The contract is now binding on the agent.</div>
|
|
65
|
+
<div class="arrow">→</div>
|
|
66
|
+
<div class="step"><b>3 · Transact</b>Idempotent execution under the intent. Every step is logged.</div>
|
|
67
|
+
<div class="arrow">→</div>
|
|
68
|
+
<div class="step"><b>4 · Receipt</b>Ed25519-signed canonical JSON. Anyone can verify it.</div>
|
|
69
|
+
<div class="arrow">→</div>
|
|
70
|
+
<div class="step"><b>5 · Compensate</b>If something goes wrong, the rollback path is explicit.</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<h2>Why this matters</h2>
|
|
74
|
+
<div class="grid">
|
|
75
|
+
<div class="card">
|
|
76
|
+
<h3>For users</h3>
|
|
77
|
+
<p>You see exactly what you authorized, what the agent did, and you can verify the receipt later — even without WAB online.</p>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="card">
|
|
80
|
+
<h3>For agents</h3>
|
|
81
|
+
<p>No more retries causing double-charges. <code>idempotency-key</code> + the intent's spend cap make safe execution structural, not aspirational.</p>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="card">
|
|
84
|
+
<h3>For sites</h3>
|
|
85
|
+
<p>Every transaction comes with a signed proof of consent. Disputes become deterministic, not he-said-she-said.</p>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="card">
|
|
88
|
+
<h3>For ecosystems</h3>
|
|
89
|
+
<p>The protocol is open. The verification endpoint is public. The receipt format is canonical JSON. No vendor lock-in.</p>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<h2>Quick start</h2>
|
|
94
|
+
<pre><code>// Node 18+ — install: npm i web-agent-bridge
|
|
95
|
+
const { ATPClient } = require('web-agent-bridge/sdk');
|
|
96
|
+
|
|
97
|
+
const atp = new ATPClient({ baseUrl: 'https://webagentbridge.com', token: USER_JWT });
|
|
98
|
+
|
|
99
|
+
// 1) The human declares the contract.
|
|
100
|
+
const intent = await atp.createIntent({
|
|
101
|
+
purpose: 'Buy a book ≤ €30',
|
|
102
|
+
scope: { actions: ['search','add_to_cart','checkout'] },
|
|
103
|
+
spend_cap_cents: 3000,
|
|
104
|
+
ttl_seconds: 600,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// 2) The human confirms.
|
|
108
|
+
await atp.authorizeIntent(intent.id);
|
|
109
|
+
|
|
110
|
+
// 3) The agent executes — idempotent by key.
|
|
111
|
+
const tx = await atp.beginTransaction({
|
|
112
|
+
intent_id: intent.id, amount_cents: 1500,
|
|
113
|
+
idempotency_key: 'order-' + Date.now(),
|
|
114
|
+
});
|
|
115
|
+
await atp.transition(tx.id, 'executing');
|
|
116
|
+
await atp.step(tx.id, { action: 'checkout.confirm', evidence: { order_id: 'X1' } });
|
|
117
|
+
await atp.transition(tx.id, 'executed');
|
|
118
|
+
await atp.transition(tx.id, 'settled');
|
|
119
|
+
|
|
120
|
+
// 4) Signed receipt — anyone can verify.
|
|
121
|
+
const receipt = await atp.issueReceipt(tx.id);
|
|
122
|
+
const verification = await atp.verifyReceipt(receipt.id);
|
|
123
|
+
console.log(verification.verification.ok); // true</code></pre>
|
|
124
|
+
|
|
125
|
+
<h2>The signed receipt</h2>
|
|
126
|
+
<p>Every receipt is a canonical JSON document signed with Ed25519. The public verification endpoint (<code>POST /api/atp/receipts/verify</code>) requires <b>no authentication</b> — that is the whole point. Anyone can hold a receipt accountable.</p>
|
|
127
|
+
<pre><code>{
|
|
128
|
+
"type": "atp.receipt.v1",
|
|
129
|
+
"receipt_id": "atp_rcpt_…",
|
|
130
|
+
"transaction": { "id": "atp_tx_…", "status": "settled", "amount_cents": 1500, … },
|
|
131
|
+
"intent": { "id": "atp_int_…", "purpose": "Buy a book ≤ €30", "scope": { … }, … },
|
|
132
|
+
"steps": [ { "seq": 1, "action": "checkout.confirm", "state": "succeeded" } ],
|
|
133
|
+
"signature": {
|
|
134
|
+
"algorithm": "ed25519",
|
|
135
|
+
"value": "BASE64…",
|
|
136
|
+
"key_id": "16-char fingerprint",
|
|
137
|
+
"public_key":"BASE64…",
|
|
138
|
+
"signed_at": "ISO-8601"
|
|
139
|
+
}
|
|
140
|
+
}</code></pre>
|
|
141
|
+
|
|
142
|
+
<h2>Open core · paid features</h2>
|
|
143
|
+
<p>The protocol and verification stay open so the standard can spread. Higher throughput, persistent key binding, and enterprise features fund the work.</p>
|
|
144
|
+
<table>
|
|
145
|
+
<thead><tr><th>Capability</th><th>Tier</th><th>Notes</th></tr></thead>
|
|
146
|
+
<tbody>
|
|
147
|
+
<tr><td>Protocol spec & SDK</td><td><span class="tier tier-free">open source</span></td><td>MIT licensed. Implement it anywhere.</td></tr>
|
|
148
|
+
<tr><td>Public receipt verification (no auth)</td><td><span class="tier tier-free">open source</span></td><td>Anyone can verify any ATP receipt.</td></tr>
|
|
149
|
+
<tr><td>Intent creation</td><td><span class="tier tier-free">free</span></td><td>10/day on Free, 50/day on Starter.</td></tr>
|
|
150
|
+
<tr><td>Receipts with persistent site key binding</td><td><span class="tier tier-pro">pro</span></td><td>500 intents/day. Continuity of trust across receipts.</td></tr>
|
|
151
|
+
<tr><td>Idempotent execution at scale</td><td><span class="tier tier-pro">pro</span></td><td>Higher quotas, audit export.</td></tr>
|
|
152
|
+
<tr><td>Compensation flows + retry classification</td><td><span class="tier tier-business">business</span></td><td>5 000 intents/day, webhook subscriptions.</td></tr>
|
|
153
|
+
<tr><td>HSM-backed signing, custom workflows, SLA</td><td><span class="tier tier-enterprise">enterprise</span></td><td>Unlimited, dedicated settlement queue.</td></tr>
|
|
154
|
+
</tbody>
|
|
155
|
+
</table>
|
|
156
|
+
|
|
157
|
+
<h2>Security posture</h2>
|
|
158
|
+
<ul>
|
|
159
|
+
<li><b>Single-use nonces.</b> Authorization burns the nonce in <code>atp_nonces</code> — replays fail at the DB layer.</li>
|
|
160
|
+
<li><b>State machine in the DB.</b> CHECK constraints make illegal transitions unrepresentable, not just unlikely.</li>
|
|
161
|
+
<li><b>Idempotency.</b> <code>UNIQUE (intent_id, idempotency_key)</code> means retries can never double-execute.</li>
|
|
162
|
+
<li><b>Spend cap on every transition.</b> Enforced server-side, not in the agent.</li>
|
|
163
|
+
<li><b>Canonical JSON signing.</b> RFC 8785-style key ordering means a tampered receipt cannot pass verification.</li>
|
|
164
|
+
<li><b>Public verify rate-limited.</b> 120 req/min per IP, cryptographic check is the answer.</li>
|
|
165
|
+
</ul>
|
|
166
|
+
|
|
167
|
+
<h2>Reference</h2>
|
|
168
|
+
<p>Full API in <a href="/docs.html">/docs.html</a> and machine-readable spec in <code>docs/SPEC.md</code>. Mount path: <code>/api/atp</code>. Source: <a href="https://github.com/abokenan444/web-agent-bridge">github.com/abokenan444/web-agent-bridge</a>.</p>
|
|
169
|
+
</main>
|
|
170
|
+
</body>
|
|
171
|
+
</html>
|
package/sdk/atp.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WAB ATP Client — Agent Transaction Primitive (v3.9.0)
|
|
5
|
+
*
|
|
6
|
+
* A tiny, zero-dependency client (uses global fetch from Node 18+) that
|
|
7
|
+
* lets agents drive the four ATP lifecycle steps against a WAB server:
|
|
8
|
+
*
|
|
9
|
+
* 1. createIntent() — declare what the user authorized
|
|
10
|
+
* 2. authorizeIntent() — user confirms the contract
|
|
11
|
+
* 3. beginTransaction() / step() / transition() — idempotent execution
|
|
12
|
+
* 4. issueReceipt() — fetch the signed receipt
|
|
13
|
+
* 5. verifyReceipt() — public verification (no auth)
|
|
14
|
+
*
|
|
15
|
+
* Example:
|
|
16
|
+
* const atp = new ATPClient({ baseUrl: 'https://webagentbridge.com', token: jwt });
|
|
17
|
+
* const intent = await atp.createIntent({
|
|
18
|
+
* purpose: 'Buy a book ≤ €30',
|
|
19
|
+
* scope: { actions: ['search','add_to_cart','checkout'] },
|
|
20
|
+
* spend_cap_cents: 3000, ttl_seconds: 600,
|
|
21
|
+
* });
|
|
22
|
+
* await atp.authorizeIntent(intent.id);
|
|
23
|
+
* const tx = await atp.beginTransaction({
|
|
24
|
+
* intent_id: intent.id, amount_cents: 1500,
|
|
25
|
+
* idempotency_key: `order-${Date.now()}`,
|
|
26
|
+
* });
|
|
27
|
+
* await atp.transition(tx.id, 'executing');
|
|
28
|
+
* await atp.step(tx.id, { action: 'checkout.confirm', evidence: { order_id: 'X1' } });
|
|
29
|
+
* await atp.transition(tx.id, 'executed');
|
|
30
|
+
* await atp.transition(tx.id, 'settled');
|
|
31
|
+
* const receipt = await atp.issueReceipt(tx.id);
|
|
32
|
+
* const ok = await atp.verifyReceipt(receipt.id);
|
|
33
|
+
* console.log('verified?', ok.verification.ok);
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
class ATPError extends Error {
|
|
37
|
+
constructor(message, { status, code, body } = {}) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = 'ATPError';
|
|
40
|
+
this.status = status;
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.body = body;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class ATPClient {
|
|
47
|
+
constructor({ baseUrl, token = null, fetchImpl = null } = {}) {
|
|
48
|
+
if (!baseUrl) throw new Error('ATPClient: baseUrl required');
|
|
49
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
50
|
+
this.token = token;
|
|
51
|
+
this._fetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
|
|
52
|
+
if (!this._fetch) throw new Error('ATPClient: no fetch available (Node 18+ or supply fetchImpl)');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async _req(method, path, { body, headers = {}, auth = true } = {}) {
|
|
56
|
+
const h = { 'content-type': 'application/json', ...headers };
|
|
57
|
+
if (auth && this.token) h.authorization = `Bearer ${this.token}`;
|
|
58
|
+
const res = await this._fetch(this.baseUrl + path, {
|
|
59
|
+
method,
|
|
60
|
+
headers: h,
|
|
61
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
62
|
+
});
|
|
63
|
+
let json = null;
|
|
64
|
+
try { json = await res.json(); } catch { /* non-JSON body */ }
|
|
65
|
+
if (!res.ok || (json && json.ok === false)) {
|
|
66
|
+
throw new ATPError(json?.message || `HTTP ${res.status}`, {
|
|
67
|
+
status: res.status, code: json?.error, body: json,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return json && json.data !== undefined ? json.data : json;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Intents ───────────────────────────────────────────────────────────────
|
|
74
|
+
createIntent(body) { return this._req('POST', '/api/atp/intents', { body }); }
|
|
75
|
+
listIntents(params = {}) {
|
|
76
|
+
const q = new URLSearchParams(params).toString();
|
|
77
|
+
return this._req('GET', '/api/atp/intents' + (q ? '?' + q : ''));
|
|
78
|
+
}
|
|
79
|
+
getIntent(id) { return this._req('GET', `/api/atp/intents/${encodeURIComponent(id)}`); }
|
|
80
|
+
authorizeIntent(id) { return this._req('POST', `/api/atp/intents/${encodeURIComponent(id)}/authorize`); }
|
|
81
|
+
revokeIntent(id, reason) { return this._req('POST', `/api/atp/intents/${encodeURIComponent(id)}/revoke`, { body: { reason } }); }
|
|
82
|
+
|
|
83
|
+
// ── Transactions ──────────────────────────────────────────────────────────
|
|
84
|
+
beginTransaction(body) {
|
|
85
|
+
const { idempotency_key, ...rest } = body;
|
|
86
|
+
const headers = idempotency_key ? { 'idempotency-key': idempotency_key } : {};
|
|
87
|
+
return this._req('POST', '/api/atp/transactions', { body: rest, headers });
|
|
88
|
+
}
|
|
89
|
+
getTransaction(id) { return this._req('GET', `/api/atp/transactions/${encodeURIComponent(id)}`); }
|
|
90
|
+
step(id, body) { return this._req('POST', `/api/atp/transactions/${encodeURIComponent(id)}/steps`, { body }); }
|
|
91
|
+
transition(id, to, extra = {}) { return this._req('POST', `/api/atp/transactions/${encodeURIComponent(id)}/transition`, { body: { to, ...extra } }); }
|
|
92
|
+
compensate(id, reason) { return this._req('POST', `/api/atp/transactions/${encodeURIComponent(id)}/compensate`, { body: { reason } }); }
|
|
93
|
+
|
|
94
|
+
// ── Receipts ──────────────────────────────────────────────────────────────
|
|
95
|
+
issueReceipt(txId) { return this._req('POST', `/api/atp/transactions/${encodeURIComponent(txId)}/receipt`); }
|
|
96
|
+
getReceipt(receiptId) { return this._req('GET', `/api/atp/receipts/${encodeURIComponent(receiptId)}`, { auth: false }); }
|
|
97
|
+
verifyReceipt(input) {
|
|
98
|
+
const body = typeof input === 'string' ? { id: input } : { receipt: input };
|
|
99
|
+
return this._req('POST', '/api/atp/receipts/verify', { body, auth: false });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { ATPClient, ATPError };
|
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,233 @@
|
|
|
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
|
+
module.exports = router;
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agent Transaction Primitive (ATP) — v3.9.0
|
|
5
|
+
*
|
|
6
|
+
* Promotes WAB from "discover + execute" to "trust + transaction" by giving
|
|
7
|
+
* agentic workflows four guarantees as first-class primitives:
|
|
8
|
+
*
|
|
9
|
+
* 1. Intent contracts — what the user authorized, with scope/cap/expiry/nonce.
|
|
10
|
+
* 2. Idempotent execution — same intent + idempotency_key never runs twice.
|
|
11
|
+
* 3. Signed receipts — Ed25519-signed canonical JSON of the outcome.
|
|
12
|
+
* 4. Compensation — explicit rollback path for each step.
|
|
13
|
+
*
|
|
14
|
+
* The DB-level CHECK constraints and UNIQUE (intent_id, idempotency_key)
|
|
15
|
+
* make illegal states unrepresentable, not just unlikely.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
const { db } = require('../models/db');
|
|
20
|
+
const wabCrypto = require('./wab-crypto');
|
|
21
|
+
|
|
22
|
+
// ── ID helpers ───────────────────────────────────────────────────────────────
|
|
23
|
+
function ulid(prefix) {
|
|
24
|
+
// 26-char base32 ulid-ish (time-sortable + random). Not RFC-strict but stable.
|
|
25
|
+
const t = Date.now().toString(36).padStart(8, '0');
|
|
26
|
+
const r = crypto.randomBytes(10).toString('hex');
|
|
27
|
+
return `${prefix}_${t}${r}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function nowIso() { return new Date().toISOString(); }
|
|
31
|
+
|
|
32
|
+
// ── Intent lifecycle ─────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const VALID_SCOPE_ACTIONS = new Set([
|
|
35
|
+
'read', 'search', 'compare', 'select', 'add_to_cart', 'checkout',
|
|
36
|
+
'submit_form', 'book', 'cancel', 'message', 'pay'
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
function validateScope(scope) {
|
|
40
|
+
if (!scope || typeof scope !== 'object') throw badRequest('scope must be an object');
|
|
41
|
+
const { actions, domains } = scope;
|
|
42
|
+
if (!Array.isArray(actions) || actions.length === 0) throw badRequest('scope.actions must be a non-empty array');
|
|
43
|
+
for (const a of actions) {
|
|
44
|
+
if (typeof a !== 'string' || !VALID_SCOPE_ACTIONS.has(a)) {
|
|
45
|
+
throw badRequest(`scope.actions contains invalid action: ${a}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (domains !== undefined) {
|
|
49
|
+
if (!Array.isArray(domains)) throw badRequest('scope.domains must be an array of hostnames');
|
|
50
|
+
for (const d of domains) {
|
|
51
|
+
if (typeof d !== 'string' || d.length === 0 || d.length > 253) throw badRequest('scope.domains contains invalid hostname');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function badRequest(msg) {
|
|
57
|
+
const e = new Error(msg); e.statusCode = 400; e.code = 'invalid_request'; return e;
|
|
58
|
+
}
|
|
59
|
+
function notFound(msg) {
|
|
60
|
+
const e = new Error(msg); e.statusCode = 404; e.code = 'not_found'; return e;
|
|
61
|
+
}
|
|
62
|
+
function conflict(msg, code = 'conflict') {
|
|
63
|
+
const e = new Error(msg); e.statusCode = 409; e.code = code; return e;
|
|
64
|
+
}
|
|
65
|
+
function forbidden(msg, code = 'forbidden') {
|
|
66
|
+
const e = new Error(msg); e.statusCode = 403; e.code = code; return e;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a draft intent. Status starts at 'draft' and requires explicit
|
|
71
|
+
* authorize() before any transaction can be executed under it.
|
|
72
|
+
*/
|
|
73
|
+
function createIntent(params) {
|
|
74
|
+
const {
|
|
75
|
+
userId, siteId = null, agentId = null,
|
|
76
|
+
purpose, scope,
|
|
77
|
+
spendCapCents = 0, spendCurrency = 'EUR',
|
|
78
|
+
maxExecutions = 1,
|
|
79
|
+
ttlSeconds = 3600,
|
|
80
|
+
metadata = {},
|
|
81
|
+
} = params;
|
|
82
|
+
|
|
83
|
+
if (!userId) throw badRequest('userId required');
|
|
84
|
+
if (!purpose || typeof purpose !== 'string' || purpose.length > 500) {
|
|
85
|
+
throw badRequest('purpose required (1-500 chars)');
|
|
86
|
+
}
|
|
87
|
+
validateScope(scope);
|
|
88
|
+
if (!Number.isInteger(spendCapCents) || spendCapCents < 0) throw badRequest('spendCapCents must be a non-negative integer');
|
|
89
|
+
if (!Number.isInteger(maxExecutions) || maxExecutions < 1 || maxExecutions > 1000) {
|
|
90
|
+
throw badRequest('maxExecutions must be 1..1000');
|
|
91
|
+
}
|
|
92
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 30 || ttlSeconds > 7 * 24 * 3600) {
|
|
93
|
+
throw badRequest('ttlSeconds must be 30..604800');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const id = ulid('atp_int');
|
|
97
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
98
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
99
|
+
|
|
100
|
+
db.prepare(`
|
|
101
|
+
INSERT INTO atp_intents (
|
|
102
|
+
id, user_id, site_id, agent_id, purpose, scope,
|
|
103
|
+
spend_cap_cents, spend_currency, max_executions, expires_at, nonce, metadata
|
|
104
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
105
|
+
`).run(
|
|
106
|
+
id, userId, siteId, agentId, purpose, JSON.stringify(scope),
|
|
107
|
+
spendCapCents, spendCurrency, maxExecutions, expiresAt, nonce, JSON.stringify(metadata)
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return getIntent(id);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getIntent(id) {
|
|
114
|
+
const row = db.prepare('SELECT * FROM atp_intents WHERE id = ?').get(id);
|
|
115
|
+
if (!row) return null;
|
|
116
|
+
return hydrateIntent(row);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hydrateIntent(row) {
|
|
120
|
+
return {
|
|
121
|
+
...row,
|
|
122
|
+
scope: safeJson(row.scope, {}),
|
|
123
|
+
metadata: safeJson(row.metadata, {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function safeJson(s, fallback) {
|
|
128
|
+
try { return JSON.parse(s); } catch { return fallback; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function listIntentsForUser(userId, { limit = 50, offset = 0 } = {}) {
|
|
132
|
+
const rows = db.prepare(`
|
|
133
|
+
SELECT * FROM atp_intents WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?
|
|
134
|
+
`).all(userId, limit, offset);
|
|
135
|
+
return rows.map(hydrateIntent);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Authorize an intent. The user (principal) confirms the contract.
|
|
140
|
+
* After this call, the intent's nonce is registered in atp_nonces to
|
|
141
|
+
* make it single-use, and the intent moves to 'authorized'.
|
|
142
|
+
*/
|
|
143
|
+
function authorizeIntent(intentId, { userId }) {
|
|
144
|
+
const intent = getIntent(intentId);
|
|
145
|
+
if (!intent) throw notFound('intent not found');
|
|
146
|
+
if (intent.user_id !== userId) throw forbidden('not your intent');
|
|
147
|
+
if (intent.status !== 'draft') throw conflict(`cannot authorize intent in status '${intent.status}'`, 'invalid_state');
|
|
148
|
+
if (new Date(intent.expires_at).getTime() < Date.now()) {
|
|
149
|
+
db.prepare("UPDATE atp_intents SET status='expired', updated_at=? WHERE id=?").run(nowIso(), intentId);
|
|
150
|
+
throw conflict('intent expired before authorization', 'expired');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const tx = db.transaction(() => {
|
|
154
|
+
// Reserve the nonce — single use across the whole user.
|
|
155
|
+
try {
|
|
156
|
+
db.prepare('INSERT INTO atp_nonces (nonce, user_id) VALUES (?, ?)').run(intent.nonce, userId);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
throw conflict('nonce already consumed', 'replay');
|
|
159
|
+
}
|
|
160
|
+
db.prepare(`
|
|
161
|
+
UPDATE atp_intents
|
|
162
|
+
SET status='authorized', authorized_at=?, authorized_by=?, updated_at=?
|
|
163
|
+
WHERE id=? AND status='draft'
|
|
164
|
+
`).run(nowIso(), userId, nowIso(), intentId);
|
|
165
|
+
});
|
|
166
|
+
tx();
|
|
167
|
+
|
|
168
|
+
return getIntent(intentId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function revokeIntent(intentId, { userId, reason = 'user_revoked' }) {
|
|
172
|
+
const intent = getIntent(intentId);
|
|
173
|
+
if (!intent) throw notFound('intent not found');
|
|
174
|
+
if (intent.user_id !== userId) throw forbidden('not your intent');
|
|
175
|
+
if (intent.status === 'consumed' || intent.status === 'revoked' || intent.status === 'expired') {
|
|
176
|
+
throw conflict(`cannot revoke intent in status '${intent.status}'`, 'invalid_state');
|
|
177
|
+
}
|
|
178
|
+
db.prepare(`
|
|
179
|
+
UPDATE atp_intents
|
|
180
|
+
SET status='revoked', revoked_at=?, revoked_reason=?, updated_at=?
|
|
181
|
+
WHERE id=?
|
|
182
|
+
`).run(nowIso(), String(reason).slice(0, 500), nowIso(), intentId);
|
|
183
|
+
return getIntent(intentId);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Transaction execution ────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Begin a transaction under an authorized intent. Idempotent on
|
|
190
|
+
* (intent_id, idempotency_key): replaying the same key returns the
|
|
191
|
+
* existing transaction instead of creating a new one.
|
|
192
|
+
*/
|
|
193
|
+
function beginTransaction(params) {
|
|
194
|
+
const {
|
|
195
|
+
intentId, idempotencyKey, siteId = null, agentId = null,
|
|
196
|
+
amountCents = 0, currency = 'EUR', summary = null, metadata = {},
|
|
197
|
+
} = params;
|
|
198
|
+
|
|
199
|
+
if (!intentId) throw badRequest('intentId required');
|
|
200
|
+
if (!idempotencyKey || typeof idempotencyKey !== 'string' || idempotencyKey.length > 200) {
|
|
201
|
+
throw badRequest('idempotencyKey required (1-200 chars)');
|
|
202
|
+
}
|
|
203
|
+
if (!Number.isInteger(amountCents) || amountCents < 0) throw badRequest('amountCents must be a non-negative integer');
|
|
204
|
+
|
|
205
|
+
const intent = getIntent(intentId);
|
|
206
|
+
if (!intent) throw notFound('intent not found');
|
|
207
|
+
if (intent.status !== 'authorized') throw conflict(`intent not authorized (status='${intent.status}')`, 'invalid_state');
|
|
208
|
+
if (new Date(intent.expires_at).getTime() < Date.now()) {
|
|
209
|
+
db.prepare("UPDATE atp_intents SET status='expired', updated_at=? WHERE id=? AND status='authorized'").run(nowIso(), intentId);
|
|
210
|
+
throw conflict('intent expired', 'expired');
|
|
211
|
+
}
|
|
212
|
+
if (intent.used_executions >= intent.max_executions) {
|
|
213
|
+
throw conflict('intent execution cap reached', 'cap_reached');
|
|
214
|
+
}
|
|
215
|
+
if (intent.spend_cap_cents > 0 && (intent.spent_cents + amountCents) > intent.spend_cap_cents) {
|
|
216
|
+
throw conflict('spend cap would be exceeded', 'spend_cap');
|
|
217
|
+
}
|
|
218
|
+
if (intent.spend_currency !== currency) {
|
|
219
|
+
throw badRequest(`currency mismatch: intent='${intent.spend_currency}', tx='${currency}'`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Idempotency check — return the existing record if same key was used.
|
|
223
|
+
const existing = db.prepare(`
|
|
224
|
+
SELECT id FROM atp_transactions WHERE intent_id=? AND idempotency_key=?
|
|
225
|
+
`).get(intentId, idempotencyKey);
|
|
226
|
+
if (existing) return { ...getTransaction(existing.id), _idempotent_replay: true };
|
|
227
|
+
|
|
228
|
+
const id = ulid('atp_tx');
|
|
229
|
+
db.prepare(`
|
|
230
|
+
INSERT INTO atp_transactions (
|
|
231
|
+
id, intent_id, site_id, agent_id, idempotency_key,
|
|
232
|
+
amount_cents, currency, summary, metadata
|
|
233
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
234
|
+
`).run(id, intentId, siteId || intent.site_id, agentId || intent.agent_id, idempotencyKey,
|
|
235
|
+
amountCents, currency, summary, JSON.stringify(metadata));
|
|
236
|
+
|
|
237
|
+
return getTransaction(id);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function getTransaction(id) {
|
|
241
|
+
const row = db.prepare('SELECT * FROM atp_transactions WHERE id=?').get(id);
|
|
242
|
+
if (!row) return null;
|
|
243
|
+
return { ...row, metadata: safeJson(row.metadata, {}) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function listTransactionsForIntent(intentId) {
|
|
247
|
+
return db.prepare('SELECT * FROM atp_transactions WHERE intent_id=? ORDER BY created_at ASC').all(intentId)
|
|
248
|
+
.map(r => ({ ...r, metadata: safeJson(r.metadata, {}) }));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const VALID_TX_TRANSITIONS = {
|
|
252
|
+
pending: ['executing', 'failed'],
|
|
253
|
+
executing: ['executed', 'failed'],
|
|
254
|
+
executed: ['settled', 'compensated', 'failed'],
|
|
255
|
+
settled: ['compensated'],
|
|
256
|
+
failed: ['compensated'],
|
|
257
|
+
compensated: [],
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
function transitionTransaction(txId, toStatus, patch = {}) {
|
|
261
|
+
const tx = getTransaction(txId);
|
|
262
|
+
if (!tx) throw notFound('transaction not found');
|
|
263
|
+
const allowed = VALID_TX_TRANSITIONS[tx.status] || [];
|
|
264
|
+
if (!allowed.includes(toStatus)) {
|
|
265
|
+
throw conflict(`illegal transition ${tx.status} → ${toStatus}`, 'invalid_state');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const fields = { status: toStatus, updated_at: nowIso() };
|
|
269
|
+
if (toStatus === 'executing') fields.started_at = nowIso();
|
|
270
|
+
if (toStatus === 'executed') fields.completed_at = nowIso();
|
|
271
|
+
if (toStatus === 'settled') fields.settled_at = nowIso();
|
|
272
|
+
if (toStatus === 'compensated') fields.compensated_at = nowIso();
|
|
273
|
+
if (patch.error !== undefined) fields.error = String(patch.error).slice(0, 2000);
|
|
274
|
+
if (patch.summary !== undefined) fields.summary = String(patch.summary).slice(0, 1000);
|
|
275
|
+
|
|
276
|
+
const sets = Object.keys(fields).map(k => `${k}=?`).join(', ');
|
|
277
|
+
const vals = Object.values(fields);
|
|
278
|
+
db.prepare(`UPDATE atp_transactions SET ${sets} WHERE id=?`).run(...vals, txId);
|
|
279
|
+
|
|
280
|
+
// On settled, charge the intent. On compensated, refund.
|
|
281
|
+
if (toStatus === 'settled') {
|
|
282
|
+
const updated = db.prepare(`
|
|
283
|
+
UPDATE atp_intents
|
|
284
|
+
SET spent_cents = spent_cents + ?,
|
|
285
|
+
used_executions = used_executions + 1,
|
|
286
|
+
updated_at = ?
|
|
287
|
+
WHERE id = ?
|
|
288
|
+
`).run(tx.amount_cents, nowIso(), tx.intent_id);
|
|
289
|
+
// Auto-consume intent if cap hit.
|
|
290
|
+
const intent = getIntent(tx.intent_id);
|
|
291
|
+
if (intent.used_executions >= intent.max_executions) {
|
|
292
|
+
db.prepare("UPDATE atp_intents SET status='consumed', updated_at=? WHERE id=? AND status='authorized'")
|
|
293
|
+
.run(nowIso(), tx.intent_id);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (toStatus === 'compensated' && tx.status === 'settled') {
|
|
297
|
+
db.prepare(`
|
|
298
|
+
UPDATE atp_intents
|
|
299
|
+
SET spent_cents = MAX(0, spent_cents - ?),
|
|
300
|
+
updated_at = ?
|
|
301
|
+
WHERE id = ?
|
|
302
|
+
`).run(tx.amount_cents, nowIso(), tx.intent_id);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return getTransaction(txId);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Step ledger ──────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
function appendStep(txId, { action, evidence = null, before = null, after = null, compensation = null }) {
|
|
311
|
+
if (!action || typeof action !== 'string') throw badRequest('step.action required');
|
|
312
|
+
const tx = getTransaction(txId);
|
|
313
|
+
if (!tx) throw notFound('transaction not found');
|
|
314
|
+
|
|
315
|
+
const nextSeqRow = db.prepare('SELECT COALESCE(MAX(seq),0)+1 AS s FROM atp_steps WHERE transaction_id=?').get(txId);
|
|
316
|
+
const seq = nextSeqRow.s;
|
|
317
|
+
db.prepare(`
|
|
318
|
+
INSERT INTO atp_steps (transaction_id, seq, action, state, before_snapshot, after_snapshot, evidence, compensation, started_at, ended_at)
|
|
319
|
+
VALUES (?, ?, ?, 'succeeded', ?, ?, ?, ?, ?, ?)
|
|
320
|
+
`).run(txId, seq, action,
|
|
321
|
+
before ? JSON.stringify(before) : null,
|
|
322
|
+
after ? JSON.stringify(after) : null,
|
|
323
|
+
evidence ? JSON.stringify(evidence) : null,
|
|
324
|
+
compensation ? JSON.stringify(compensation) : null,
|
|
325
|
+
nowIso(), nowIso());
|
|
326
|
+
return getStep(txId, seq);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function getStep(txId, seq) {
|
|
330
|
+
const row = db.prepare('SELECT * FROM atp_steps WHERE transaction_id=? AND seq=?').get(txId, seq);
|
|
331
|
+
if (!row) return null;
|
|
332
|
+
return {
|
|
333
|
+
...row,
|
|
334
|
+
before_snapshot: safeJson(row.before_snapshot, null),
|
|
335
|
+
after_snapshot: safeJson(row.after_snapshot, null),
|
|
336
|
+
evidence: safeJson(row.evidence, null),
|
|
337
|
+
compensation: safeJson(row.compensation, null),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function listSteps(txId) {
|
|
342
|
+
return db.prepare('SELECT * FROM atp_steps WHERE transaction_id=? ORDER BY seq ASC').all(txId)
|
|
343
|
+
.map(r => ({
|
|
344
|
+
...r,
|
|
345
|
+
before_snapshot: safeJson(r.before_snapshot, null),
|
|
346
|
+
after_snapshot: safeJson(r.after_snapshot, null),
|
|
347
|
+
evidence: safeJson(r.evidence, null),
|
|
348
|
+
compensation: safeJson(r.compensation, null),
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── Receipts (signed proof of outcome) ───────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Issue a signed receipt for an executed transaction. The receipt body is
|
|
356
|
+
* canonicalized via wab-crypto and signed Ed25519 with the supplied private
|
|
357
|
+
* key (typically the site's key from `wab_signing_keys`).
|
|
358
|
+
*
|
|
359
|
+
* If no privateKey is supplied, an ephemeral keypair is generated and the
|
|
360
|
+
* public key is embedded in the receipt so verifiers can still check it.
|
|
361
|
+
* This keeps the free tier usable while encouraging Pro+ users to bind a
|
|
362
|
+
* persistent site key for trust continuity.
|
|
363
|
+
*/
|
|
364
|
+
function issueReceipt(txId, { privateKeyB64 = null, embedPublicKey = true } = {}) {
|
|
365
|
+
const tx = getTransaction(txId);
|
|
366
|
+
if (!tx) throw notFound('transaction not found');
|
|
367
|
+
if (!['executed', 'settled', 'failed', 'compensated'].includes(tx.status)) {
|
|
368
|
+
throw conflict(`cannot issue receipt for status '${tx.status}'`, 'invalid_state');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Refuse double-issuance.
|
|
372
|
+
const existing = db.prepare('SELECT id FROM atp_receipts WHERE transaction_id=?').get(txId);
|
|
373
|
+
if (existing) return getReceipt(existing.id);
|
|
374
|
+
|
|
375
|
+
const steps = listSteps(txId);
|
|
376
|
+
const intent = getIntent(tx.intent_id);
|
|
377
|
+
|
|
378
|
+
const body = {
|
|
379
|
+
type: 'atp.receipt.v1',
|
|
380
|
+
receipt_id: ulid('atp_rcpt'),
|
|
381
|
+
issued_at: nowIso(),
|
|
382
|
+
transaction: {
|
|
383
|
+
id: tx.id,
|
|
384
|
+
status: tx.status,
|
|
385
|
+
amount_cents: tx.amount_cents,
|
|
386
|
+
currency: tx.currency,
|
|
387
|
+
summary: tx.summary,
|
|
388
|
+
started_at: tx.started_at,
|
|
389
|
+
completed_at: tx.completed_at,
|
|
390
|
+
settled_at: tx.settled_at,
|
|
391
|
+
compensated_at: tx.compensated_at,
|
|
392
|
+
error: tx.error,
|
|
393
|
+
},
|
|
394
|
+
intent: {
|
|
395
|
+
id: intent.id,
|
|
396
|
+
purpose: intent.purpose,
|
|
397
|
+
scope: intent.scope,
|
|
398
|
+
spend_cap_cents: intent.spend_cap_cents,
|
|
399
|
+
currency: intent.spend_currency,
|
|
400
|
+
authorized_at: intent.authorized_at,
|
|
401
|
+
},
|
|
402
|
+
steps: steps.map(s => ({
|
|
403
|
+
seq: s.seq, action: s.action, state: s.state,
|
|
404
|
+
attempts: s.attempts,
|
|
405
|
+
started_at: s.started_at, ended_at: s.ended_at,
|
|
406
|
+
})),
|
|
407
|
+
site_id: tx.site_id || null,
|
|
408
|
+
agent_id: tx.agent_id || null,
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// Decide signing key.
|
|
412
|
+
let signKey = privateKeyB64;
|
|
413
|
+
let publicKeyB64 = null;
|
|
414
|
+
let keyOrigin = 'supplied';
|
|
415
|
+
if (!signKey) {
|
|
416
|
+
const kp = wabCrypto.generateKeyPair();
|
|
417
|
+
signKey = kp.private_key;
|
|
418
|
+
publicKeyB64 = kp.public_key;
|
|
419
|
+
keyOrigin = 'ephemeral';
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const signed = wabCrypto.signManifest(body, signKey, { embed_public_key: embedPublicKey });
|
|
423
|
+
// wab-crypto already embedded the pub key into signed.signature.public_key if requested,
|
|
424
|
+
// but we also want the raw pubkey for column storage:
|
|
425
|
+
if (!publicKeyB64) publicKeyB64 = signed.signature.public_key || null;
|
|
426
|
+
|
|
427
|
+
const canonical = canonicalizeForStorage(signed);
|
|
428
|
+
|
|
429
|
+
const id = body.receipt_id;
|
|
430
|
+
db.prepare(`
|
|
431
|
+
INSERT INTO atp_receipts (id, transaction_id, site_id, algorithm, key_id, canonical_body, signature, public_key)
|
|
432
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
433
|
+
`).run(id, txId, tx.site_id, 'ed25519',
|
|
434
|
+
signed.signature.key_id, canonical, signed.signature.value, publicKeyB64);
|
|
435
|
+
|
|
436
|
+
return { ...getReceipt(id), _key_origin: keyOrigin };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function canonicalizeForStorage(signedManifest) {
|
|
440
|
+
// Store the FULL signed object as JSON; verifiers recompute canonical from this.
|
|
441
|
+
return JSON.stringify(signedManifest);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function getReceipt(id) {
|
|
445
|
+
const row = db.prepare('SELECT * FROM atp_receipts WHERE id=?').get(id);
|
|
446
|
+
if (!row) return null;
|
|
447
|
+
let body = null;
|
|
448
|
+
try { body = JSON.parse(row.canonical_body); } catch { /* keep null */ }
|
|
449
|
+
return { ...row, body };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function getReceiptByTransaction(txId) {
|
|
453
|
+
const row = db.prepare('SELECT id FROM atp_receipts WHERE transaction_id=?').get(txId);
|
|
454
|
+
return row ? getReceipt(row.id) : null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Verify a receipt. Accepts either:
|
|
459
|
+
* - a receipt id (looked up in DB), or
|
|
460
|
+
* - a raw signed receipt object (offline verification).
|
|
461
|
+
* Returns { ok, reason?, key_id?, age_seconds? }.
|
|
462
|
+
*/
|
|
463
|
+
function verifyReceipt(input) {
|
|
464
|
+
let signed = null;
|
|
465
|
+
let stored = null;
|
|
466
|
+
if (typeof input === 'string') {
|
|
467
|
+
stored = getReceipt(input);
|
|
468
|
+
if (!stored) return { ok: false, reason: 'receipt not found' };
|
|
469
|
+
signed = stored.body;
|
|
470
|
+
} else if (input && typeof input === 'object') {
|
|
471
|
+
signed = input;
|
|
472
|
+
} else {
|
|
473
|
+
return { ok: false, reason: 'invalid input' };
|
|
474
|
+
}
|
|
475
|
+
if (!signed || !signed.signature) return { ok: false, reason: 'no signature' };
|
|
476
|
+
|
|
477
|
+
const pubB64 = signed.signature.public_key || (stored && stored.public_key) || null;
|
|
478
|
+
const result = wabCrypto.verifyManifest(signed, pubB64, { max_age_seconds: 365 * 24 * 3600 });
|
|
479
|
+
return result;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── Compensation ─────────────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Compensate a transaction: rolls back its effects. This is the explicit
|
|
486
|
+
* "undo" primitive that distinguishes WAB from naive scrapers — every
|
|
487
|
+
* executed step can carry its own compensation descriptor in its evidence.
|
|
488
|
+
*
|
|
489
|
+
* This function just transitions the state and unwinds the intent's spend
|
|
490
|
+
* counter. Actual site-side rollback (e.g. cancelling a booking) is the
|
|
491
|
+
* caller's responsibility and should be recorded as further steps before
|
|
492
|
+
* calling this function.
|
|
493
|
+
*/
|
|
494
|
+
function compensateTransaction(txId, { reason = 'compensated' } = {}) {
|
|
495
|
+
return transitionTransaction(txId, 'compensated', { summary: String(reason).slice(0, 1000) });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── Periodic maintenance ─────────────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
function expireOverdueIntents() {
|
|
501
|
+
const r = db.prepare(`
|
|
502
|
+
UPDATE atp_intents
|
|
503
|
+
SET status='expired', updated_at=datetime('now')
|
|
504
|
+
WHERE status IN ('draft','authorized')
|
|
505
|
+
AND datetime(expires_at) < datetime('now')
|
|
506
|
+
`).run();
|
|
507
|
+
return r.changes;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
module.exports = {
|
|
511
|
+
// intents
|
|
512
|
+
createIntent, getIntent, listIntentsForUser, authorizeIntent, revokeIntent,
|
|
513
|
+
// transactions
|
|
514
|
+
beginTransaction, getTransaction, listTransactionsForIntent, transitionTransaction,
|
|
515
|
+
// steps
|
|
516
|
+
appendStep, getStep, listSteps,
|
|
517
|
+
// receipts
|
|
518
|
+
issueReceipt, getReceipt, getReceiptByTransaction, verifyReceipt,
|
|
519
|
+
// compensation
|
|
520
|
+
compensateTransaction,
|
|
521
|
+
// maintenance
|
|
522
|
+
expireOverdueIntents,
|
|
523
|
+
// re-exports for tests
|
|
524
|
+
_validateScope: validateScope,
|
|
525
|
+
};
|