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/README.ar.md +2 -0
- package/README.md +37 -3
- package/package.json +2 -2
- package/public/atp.html +174 -0
- package/public/transparency.html +285 -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 +248 -0
- package/server/services/stripe.js +19 -0
- package/server/services/transactions.js +675 -0
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;
|