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.
@@ -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
+ };