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
|
@@ -0,0 +1,675 @@
|
|
|
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
|
+
// ── Platform self-dogfooding ─────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Record a real WAB platform money event (e.g. Stripe subscription payment)
|
|
514
|
+
* as a complete ATP cycle: intent → authorize → tx → step → settle → receipt.
|
|
515
|
+
*
|
|
516
|
+
* This is "eat-your-own-cooking": every dollar that flows into WAB is itself
|
|
517
|
+
* a publicly-verifiable Ed25519 receipt that any auditor can re-verify.
|
|
518
|
+
*
|
|
519
|
+
* Idempotent on (externalRef): replaying the same Stripe invoice id is a
|
|
520
|
+
* no-op and returns the existing receipt.
|
|
521
|
+
*/
|
|
522
|
+
function recordPlatformPayment(params) {
|
|
523
|
+
const {
|
|
524
|
+
userId, amountCents, currency = 'USD', tier,
|
|
525
|
+
externalRef, description = null,
|
|
526
|
+
periodStart = null, periodEnd = null,
|
|
527
|
+
provider = 'stripe',
|
|
528
|
+
} = params;
|
|
529
|
+
|
|
530
|
+
if (!userId) throw badRequest('userId required');
|
|
531
|
+
if (!Number.isInteger(amountCents) || amountCents < 0) throw badRequest('amountCents must be non-negative integer');
|
|
532
|
+
if (!externalRef || typeof externalRef !== 'string') throw badRequest('externalRef required');
|
|
533
|
+
if (!tier || typeof tier !== 'string') throw badRequest('tier required');
|
|
534
|
+
|
|
535
|
+
// Idempotency: if a receipt already exists for this external ref, return it.
|
|
536
|
+
const prior = db.prepare(`
|
|
537
|
+
SELECT r.id AS rid
|
|
538
|
+
FROM atp_receipts r
|
|
539
|
+
JOIN atp_transactions t ON t.id = r.transaction_id
|
|
540
|
+
JOIN atp_intents i ON i.id = t.intent_id
|
|
541
|
+
WHERE json_extract(i.metadata, '$.platform') = 1
|
|
542
|
+
AND json_extract(i.metadata, '$.external_ref') = ?
|
|
543
|
+
LIMIT 1
|
|
544
|
+
`).get(externalRef);
|
|
545
|
+
if (prior) return getReceipt(prior.rid);
|
|
546
|
+
|
|
547
|
+
const cur = String(currency || 'USD').toUpperCase();
|
|
548
|
+
const meta = {
|
|
549
|
+
platform: true,
|
|
550
|
+
kind: 'wab_subscription',
|
|
551
|
+
tier,
|
|
552
|
+
provider,
|
|
553
|
+
external_ref: externalRef,
|
|
554
|
+
period_start: periodStart,
|
|
555
|
+
period_end: periodEnd,
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
// 1. Intent
|
|
559
|
+
const intent = createIntent({
|
|
560
|
+
userId,
|
|
561
|
+
purpose: `WAB platform subscription — ${tier}`,
|
|
562
|
+
scope: { actions: ['pay'] },
|
|
563
|
+
spendCapCents: amountCents,
|
|
564
|
+
spendCurrency: cur,
|
|
565
|
+
maxExecutions: 1,
|
|
566
|
+
ttlSeconds: 3600,
|
|
567
|
+
metadata: meta,
|
|
568
|
+
});
|
|
569
|
+
// 2. Authorize
|
|
570
|
+
authorizeIntent(intent.id, { userId });
|
|
571
|
+
// 3. Begin tx (idempotent on externalRef)
|
|
572
|
+
const tx = beginTransaction({
|
|
573
|
+
intentId: intent.id,
|
|
574
|
+
idempotencyKey: externalRef,
|
|
575
|
+
amountCents,
|
|
576
|
+
currency: cur,
|
|
577
|
+
summary: description || `Subscription payment for ${tier}`,
|
|
578
|
+
metadata: { external_ref: externalRef, provider },
|
|
579
|
+
});
|
|
580
|
+
// 4. Step (the actual payment evidence)
|
|
581
|
+
appendStep(tx.id, {
|
|
582
|
+
action: 'pay',
|
|
583
|
+
evidence: {
|
|
584
|
+
provider,
|
|
585
|
+
external_ref: externalRef,
|
|
586
|
+
amount_cents: amountCents,
|
|
587
|
+
currency: cur,
|
|
588
|
+
tier,
|
|
589
|
+
period_start: periodStart,
|
|
590
|
+
period_end: periodEnd,
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
// 5. Drive lifecycle to settled
|
|
594
|
+
transitionTransaction(tx.id, 'executing');
|
|
595
|
+
transitionTransaction(tx.id, 'executed');
|
|
596
|
+
transitionTransaction(tx.id, 'settled');
|
|
597
|
+
// 6. Issue receipt
|
|
598
|
+
return issueReceipt(tx.id);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* List platform-issued receipts (the "transparency feed").
|
|
603
|
+
* Public-safe: filters to intents with metadata.platform=1 only.
|
|
604
|
+
*/
|
|
605
|
+
function listPlatformReceipts({ limit = 20, offset = 0 } = {}) {
|
|
606
|
+
const lim = Math.max(1, Math.min(100, Number(limit) || 20));
|
|
607
|
+
const off = Math.max(0, Number(offset) || 0);
|
|
608
|
+
const rows = db.prepare(`
|
|
609
|
+
SELECT r.id AS receipt_id,
|
|
610
|
+
r.issued_at AS issued_at,
|
|
611
|
+
r.algorithm AS algorithm,
|
|
612
|
+
r.key_id AS key_id,
|
|
613
|
+
t.amount_cents AS amount_cents,
|
|
614
|
+
t.currency AS currency,
|
|
615
|
+
t.status AS tx_status,
|
|
616
|
+
json_extract(i.metadata, '$.tier') AS tier,
|
|
617
|
+
json_extract(i.metadata, '$.provider') AS provider,
|
|
618
|
+
json_extract(i.metadata, '$.period_start') AS period_start,
|
|
619
|
+
json_extract(i.metadata, '$.period_end') AS period_end
|
|
620
|
+
FROM atp_receipts r
|
|
621
|
+
JOIN atp_transactions t ON t.id = r.transaction_id
|
|
622
|
+
JOIN atp_intents i ON i.id = t.intent_id
|
|
623
|
+
WHERE json_extract(i.metadata, '$.platform') = 1
|
|
624
|
+
ORDER BY r.issued_at DESC
|
|
625
|
+
LIMIT ? OFFSET ?
|
|
626
|
+
`).all(lim, off);
|
|
627
|
+
return rows;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Aggregate stats for the public transparency page.
|
|
632
|
+
*/
|
|
633
|
+
function getPlatformStats() {
|
|
634
|
+
const row = db.prepare(`
|
|
635
|
+
SELECT COUNT(*) AS receipts,
|
|
636
|
+
COALESCE(SUM(t.amount_cents), 0) AS total_cents,
|
|
637
|
+
MIN(r.issued_at) AS first_at,
|
|
638
|
+
MAX(r.issued_at) AS last_at
|
|
639
|
+
FROM atp_receipts r
|
|
640
|
+
JOIN atp_transactions t ON t.id = r.transaction_id
|
|
641
|
+
JOIN atp_intents i ON i.id = t.intent_id
|
|
642
|
+
WHERE json_extract(i.metadata, '$.platform') = 1
|
|
643
|
+
`).get();
|
|
644
|
+
const byTier = db.prepare(`
|
|
645
|
+
SELECT json_extract(i.metadata, '$.tier') AS tier,
|
|
646
|
+
COUNT(*) AS receipts,
|
|
647
|
+
COALESCE(SUM(t.amount_cents), 0) AS total_cents
|
|
648
|
+
FROM atp_receipts r
|
|
649
|
+
JOIN atp_transactions t ON t.id = r.transaction_id
|
|
650
|
+
JOIN atp_intents i ON i.id = t.intent_id
|
|
651
|
+
WHERE json_extract(i.metadata, '$.platform') = 1
|
|
652
|
+
GROUP BY tier
|
|
653
|
+
ORDER BY total_cents DESC
|
|
654
|
+
`).all();
|
|
655
|
+
return { ...row, by_tier: byTier };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
module.exports = {
|
|
659
|
+
// intents
|
|
660
|
+
createIntent, getIntent, listIntentsForUser, authorizeIntent, revokeIntent,
|
|
661
|
+
// transactions
|
|
662
|
+
beginTransaction, getTransaction, listTransactionsForIntent, transitionTransaction,
|
|
663
|
+
// steps
|
|
664
|
+
appendStep, getStep, listSteps,
|
|
665
|
+
// receipts
|
|
666
|
+
issueReceipt, getReceipt, getReceiptByTransaction, verifyReceipt,
|
|
667
|
+
// compensation
|
|
668
|
+
compensateTransaction,
|
|
669
|
+
// maintenance
|
|
670
|
+
expireOverdueIntents,
|
|
671
|
+
// platform dogfooding
|
|
672
|
+
recordPlatformPayment, listPlatformReceipts, getPlatformStats,
|
|
673
|
+
// re-exports for tests
|
|
674
|
+
_validateScope: validateScope,
|
|
675
|
+
};
|