@forum-labs/payfetch 1.0.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +467 -0
  3. package/dist/bin/payfetch-mcp.d.ts +2 -0
  4. package/dist/bin/payfetch-mcp.js +9 -0
  5. package/dist/bin/payfetch.d.ts +2 -0
  6. package/dist/bin/payfetch.js +126 -0
  7. package/dist/src/config.d.ts +35 -0
  8. package/dist/src/config.js +170 -0
  9. package/dist/src/core/budget.d.ts +62 -0
  10. package/dist/src/core/budget.js +236 -0
  11. package/dist/src/core/constants.d.ts +66 -0
  12. package/dist/src/core/constants.js +115 -0
  13. package/dist/src/core/fs.d.ts +14 -0
  14. package/dist/src/core/fs.js +104 -0
  15. package/dist/src/core/integrity.d.ts +23 -0
  16. package/dist/src/core/integrity.js +150 -0
  17. package/dist/src/core/ledger.d.ts +149 -0
  18. package/dist/src/core/ledger.js +357 -0
  19. package/dist/src/core/notes.d.ts +22 -0
  20. package/dist/src/core/notes.js +24 -0
  21. package/dist/src/core/pipeline.d.ts +143 -0
  22. package/dist/src/core/pipeline.js +795 -0
  23. package/dist/src/core/policy.d.ts +57 -0
  24. package/dist/src/core/policy.js +224 -0
  25. package/dist/src/core/transport.d.ts +93 -0
  26. package/dist/src/core/transport.js +364 -0
  27. package/dist/src/guards/index.d.ts +4 -0
  28. package/dist/src/guards/index.js +3 -0
  29. package/dist/src/guards/internal.d.ts +17 -0
  30. package/dist/src/guards/internal.js +74 -0
  31. package/dist/src/guards/safety.d.ts +2 -0
  32. package/dist/src/guards/safety.js +94 -0
  33. package/dist/src/guards/trust.d.ts +2 -0
  34. package/dist/src/guards/trust.js +79 -0
  35. package/dist/src/guards/types.d.ts +59 -0
  36. package/dist/src/guards/types.js +20 -0
  37. package/dist/src/index.d.ts +58 -0
  38. package/dist/src/index.js +151 -0
  39. package/dist/src/mcp/server.d.ts +6 -0
  40. package/dist/src/mcp/server.js +125 -0
  41. package/dist/src/mcp/tools.d.ts +46 -0
  42. package/dist/src/mcp/tools.js +321 -0
  43. package/dist/src/payer/mpp.d.ts +7 -0
  44. package/dist/src/payer/mpp.js +13 -0
  45. package/dist/src/payer/parse402.d.ts +6 -0
  46. package/dist/src/payer/parse402.js +169 -0
  47. package/dist/src/payer/signer_cdp.d.ts +14 -0
  48. package/dist/src/payer/signer_cdp.js +64 -0
  49. package/dist/src/payer/signer_local.d.ts +8 -0
  50. package/dist/src/payer/signer_local.js +14 -0
  51. package/dist/src/payer/types.d.ts +99 -0
  52. package/dist/src/payer/types.js +10 -0
  53. package/dist/src/payer/x402.d.ts +23 -0
  54. package/dist/src/payer/x402.js +165 -0
  55. package/dist/src/report/report.d.ts +162 -0
  56. package/dist/src/report/report.js +220 -0
  57. package/mcpb/manifest.json +104 -0
  58. package/package.json +72 -0
@@ -0,0 +1,795 @@
1
+ import { APPROVAL_ELICIT_TIMEOUT_S, CLIENT_VERSION, GUARD_SEND_QUERY, INTEGRATION_HEADER, guardBudgetMs, KNOWN_ASSETS, PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER, POLICY_VERSION, X_PAYMENT_HEADER, X_PAYMENT_RESPONSE_HEADER, } from "./constants.js";
2
+ import { guardDayKey, utcDate, } from "./ledger.js";
3
+ import { budgetExhausted, elicitUnsupportedFallback, guardBlocked, guardUnavailable, guardWarn, preapproved, } from "./notes.js";
4
+ import { matchesAnyHost } from "./policy.js";
5
+ import { deliverBody, transportFetch, } from "./transport.js";
6
+ import { parse402Challenge, parseChallenge } from "../payer/parse402.js";
7
+ import { parseSettlementResponse, quoteWithRejections, selectQuote, } from "../payer/x402.js";
8
+ const defaultDelay = (ms) => new Promise((r) => {
9
+ const t = setTimeout(r, ms);
10
+ t.unref?.();
11
+ });
12
+ function hex(bytes) {
13
+ let s = "";
14
+ for (const b of bytes)
15
+ s += b.toString(16).padStart(2, "0");
16
+ return s;
17
+ }
18
+ function uuid4FromBytes(b) {
19
+ const h = hex(b.subarray(0, 16)).split("");
20
+ h[12] = "4";
21
+ const variant = (parseInt(h[16], 16) & 0x3) | 0x8;
22
+ h[16] = variant.toString(16);
23
+ const s = h.join("");
24
+ return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
25
+ }
26
+ export class PayfetchEngine {
27
+ #cfg;
28
+ #deps;
29
+ #delay;
30
+ guards = [];
31
+ constructor(cfg) {
32
+ this.#cfg = cfg;
33
+ this.#deps = cfg.deps;
34
+ this.#delay = cfg.delay ?? defaultDelay;
35
+ }
36
+ newReceiptId() {
37
+ return uuid4FromBytes(this.#deps.random());
38
+ }
39
+ capsOf(policy) {
40
+ return {
41
+ dailyUsd: policy.caps.dailyUsd,
42
+ perHostDailyUsd: policy.caps.perHostDailyUsd,
43
+ totalUsd: policy.caps.totalUsd,
44
+ };
45
+ }
46
+ transportIo() {
47
+ return {
48
+ ...this.#cfg.transportIo,
49
+ now: this.#deps.now,
50
+ log: this.#deps.log,
51
+ fs: this.#cfg.fs,
52
+ };
53
+ }
54
+ async fetch(url, init, opts) {
55
+ return this.run(url, init ?? {}, opts ?? {}, false);
56
+ }
57
+ async quote(url, init) {
58
+ const { receipt } = await this.run(url, init ?? {}, { dryRun: true }, true);
59
+ return { decision: this.decisionFromReceipt(receipt), receipt };
60
+ }
61
+ async status() {
62
+ this.#cfg.budget.sweepExpired();
63
+ const pol = this.#cfg.policyProvider();
64
+ const caps = pol.ok ? this.capsOf(pol.policy) : { dailyUsd: 0, perHostDailyUsd: 0, totalUsd: null };
65
+ const now = this.#deps.now();
66
+ const date = utcDate(now);
67
+ const state = this.#cfg.budget.state;
68
+ const dayKeyStr = `day:${date}`;
69
+ const daySpent = state.counters[dayKeyStr] ?? 0;
70
+ const totalSpent = state.counters["total"] ?? 0;
71
+ const perHost = {};
72
+ for (const [k, v] of Object.entries(state.counters)) {
73
+ const m = /^host:(.+):(\d{4}-\d{2}-\d{2})$/.exec(k);
74
+ if (m && m[2] === date) {
75
+ const host = m[1];
76
+ const rem = this.#cfg.budget.remaining(caps, host);
77
+ perHost[host] = { spentUsd: v, remainingUsd: rem.hostRemainingUsd };
78
+ }
79
+ }
80
+ const remaining = this.#cfg.budget.remaining(caps, "");
81
+ const recent = this.#cfg.ledger
82
+ .readAllReceipts()
83
+ .filter((r) => r.payment !== null)
84
+ .sort((a, b) => b.ts - a.ts)
85
+ .slice(0, 10)
86
+ .map((r) => ({
87
+ receiptId: r.receiptId,
88
+ host: r.host,
89
+ amountUsd: r.payment?.settledAmountUsd ?? r.quote?.amountUsd ?? null,
90
+ outcome: r.outcome,
91
+ ts: r.ts,
92
+ }));
93
+ return {
94
+ date,
95
+ day: { spentUsd: daySpent, remainingUsd: remaining.dayRemainingUsd },
96
+ total: { spentUsd: totalSpent, remainingUsd: remaining.totalRemainingUsd },
97
+ perHost,
98
+ holds: state.holds.map((h) => ({
99
+ holdId: h.holdId,
100
+ amountUsd: h.amountUsd,
101
+ host: h.host,
102
+ validBeforeTs: h.validBeforeTs,
103
+ })),
104
+ autoDenied: this.#cfg.budget.autoDeniedHosts(),
105
+ recentPayments: recent,
106
+ };
107
+ }
108
+ receipts(q) {
109
+ const limit = Math.min(Math.max(q.limit ?? 50, 1), 200);
110
+ let rs = this.#cfg.ledger.readAllReceipts();
111
+ if (q.sinceTs !== undefined)
112
+ rs = rs.filter((r) => r.ts >= q.sinceTs);
113
+ if (q.host !== undefined)
114
+ rs = rs.filter((r) => r.host === q.host);
115
+ if (q.outcome !== undefined)
116
+ rs = rs.filter((r) => r.outcome === q.outcome);
117
+ rs.sort((a, b) => b.ts - a.ts);
118
+ return rs.slice(0, limit);
119
+ }
120
+ listApprovals() {
121
+ return this.#cfg.budget.listApprovals();
122
+ }
123
+ queueCapableNow() {
124
+ const p = this.#cfg.policyProvider();
125
+ if (!p.ok)
126
+ return false;
127
+ const a = p.policy.approval;
128
+ return a.mode === "queue" || (a.mode === "elicit" && a.elicitFallback === "queue");
129
+ }
130
+ resolveApproval(approvalId, approve) {
131
+ if (!this.#cfg.approverEnabled)
132
+ return { ok: false, error: "approver_not_enabled" };
133
+ if (approve && this.queueCapableNow()) {
134
+ return { ok: false, error: "queue_self_approval_forbidden" };
135
+ }
136
+ const g = this.#cfg.budget.resolvePending(approvalId, approve);
137
+ return g ? { ok: true } : { ok: false, error: "approval_not_found" };
138
+ }
139
+ async run(url, init, opts, quoteMode) {
140
+ const deps = this.#deps;
141
+ const receiptId = this.newReceiptId();
142
+ const verdictPath = [];
143
+ const notes = [];
144
+ if (this.#cfg.testMode)
145
+ notes.push("test_mode");
146
+ this.#cfg.budget.sweepExpired();
147
+ const pol = this.#cfg.policyProvider();
148
+ if (!pol.ok) {
149
+ return this.finish(this.baseReceipt(receiptId, url, init, "", "policy_denied", verdictPath, notes, null, {
150
+ dayRemainingUsd: 0,
151
+ hostRemainingUsd: 0,
152
+ totalRemainingUsd: null,
153
+ }, { denyCode: "policy_config_invalid", notesExtra: [pol.error] }), null);
154
+ }
155
+ const policy = pol.policy;
156
+ const caps = this.capsOf(policy);
157
+ const method = (init.method ?? "GET").toUpperCase();
158
+ const userHeaders = stripReservedHeaders(init.headers);
159
+ const body = typeof init.body === "string" ? init.body : null;
160
+ const responseMode = opts.responseMode ?? "inline";
161
+ const dry = quoteMode || Boolean(opts.dryRun);
162
+ const io = this.transportIo();
163
+ const leg1 = await transportFetch(url, { method, headers: userHeaders, body }, policy, io);
164
+ for (const n of leg1.notes)
165
+ notes.push(n);
166
+ if (!leg1.ok) {
167
+ const budgets = this.#cfg.budget.remaining(caps, leg1.finalHost);
168
+ const outcome = "fetch_error";
169
+ const receipt = this.baseReceipt(receiptId, url, init, leg1.finalHost, outcome, verdictPath, notes, null, budgets, { http: this.httpBlock(leg1) });
170
+ return this.finish(receipt, null);
171
+ }
172
+ const finalHost = leg1.finalHost;
173
+ const budgetsAtDecision = this.#cfg.budget.remaining(caps, finalHost);
174
+ if (leg1.status !== 402) {
175
+ const delivered = deliverBody(leg1.rawBody ?? new Uint8Array(0), responseMode, {
176
+ fs: io.fs,
177
+ downloadPath: this.#cfg.ledger.downloadPath(receiptId),
178
+ });
179
+ if (delivered.mode === "inline" && delivered.truncated)
180
+ notes.push("body_truncated");
181
+ const receipt = this.baseReceipt(receiptId, url, init, finalHost, "free", verdictPath, notes, null, budgetsAtDecision, { http: this.httpBlock(leg1) });
182
+ return this.finish(receipt, this.materialize(leg1, delivered));
183
+ }
184
+ const challenge = parse402Challenge(leg1.rawBody ?? new Uint8Array(0), leg1.headers?.get(PAYMENT_REQUIRED_HEADER) ?? null);
185
+ verdictPath.push("parse");
186
+ if (challenge.malformed) {
187
+ return this.deny(receiptId, url, init, finalHost, verdictPath, notes, null, budgetsAtDecision, "malformed_402", ["malformed_402"], "policy_denied", leg1);
188
+ }
189
+ verdictPath.push("quotes");
190
+ const payer = this.detectPayer(challenge);
191
+ const { quotes, rejected } = this.quoteAndReject(challenge, payer);
192
+ if (quotes.length === 0) {
193
+ const rejNotes = tallyNotes(rejected);
194
+ return this.deny(receiptId, url, init, finalHost, verdictPath, notes, null, budgetsAtDecision, "unsupported_terms", ["unsupported_terms", ...rejNotes], "policy_denied", leg1, rejected);
195
+ }
196
+ verdictPath.push("select");
197
+ const quote = selectQuote(quotes);
198
+ if (quote === null) {
199
+ return this.deny(receiptId, url, init, finalHost, verdictPath, notes, null, budgetsAtDecision, "unsupported_terms", ["unsupported_terms"], "policy_denied", leg1, rejected);
200
+ }
201
+ if (this.#cfg.testMode && quote.network === "base") {
202
+ return this.deny(receiptId, url, init, finalHost, verdictPath, notes, quote, budgetsAtDecision, "test_mode", ["test_mode"], "policy_denied", leg1, rejected);
203
+ }
204
+ verdictPath.push("deny_list");
205
+ if (matchesAnyHost(finalHost, policy.deny)) {
206
+ return this.deny(receiptId, url, init, finalHost, verdictPath, notes, quote, budgetsAtDecision, "host_denied", ["host_denied"], "policy_denied", leg1, rejected);
207
+ }
208
+ verdictPath.push("allow_list");
209
+ if (policy.mode === "allowlist" && !matchesAnyHost(finalHost, policy.allow)) {
210
+ return this.deny(receiptId, url, init, finalHost, verdictPath, notes, quote, budgetsAtDecision, "host_not_allowlisted", ["host_not_allowlisted"], "policy_denied", leg1, rejected);
211
+ }
212
+ verdictPath.push("auto_deny");
213
+ if (policy.autoDeny.enabled && this.#cfg.budget.isAutoDenied(finalHost)) {
214
+ return this.deny(receiptId, url, init, finalHost, verdictPath, notes, quote, budgetsAtDecision, "host_auto_denied", ["host_auto_denied"], "policy_denied", leg1, rejected);
215
+ }
216
+ verdictPath.push("per_call");
217
+ const effectivePerCall = Math.min(policy.caps.perCallUsd, opts.maxAmountUsd ?? Number.POSITIVE_INFINITY);
218
+ if (microUsd(quote.amountUsd) > microUsd(effectivePerCall)) {
219
+ return this.deny(receiptId, url, init, finalHost, verdictPath, notes, quote, budgetsAtDecision, "per_call_cap_exceeded", ["per_call_cap_exceeded"], "policy_denied", leg1, rejected);
220
+ }
221
+ verdictPath.push("guards");
222
+ const guardInput = {
223
+ url: guardUrl(leg1.finalUrl),
224
+ host: finalHost,
225
+ quote,
226
+ context: {
227
+ tokenAddress: opts.tokenAddress,
228
+ chain: opts.chain,
229
+ },
230
+ dryRun: dry,
231
+ };
232
+ const guardResults = [];
233
+ for (const guard of this.guards) {
234
+ const cfg = guard.id === "trust" ? policy.guards.trust : policy.guards.safety;
235
+ if (!cfg.enabled)
236
+ continue;
237
+ if (!guard.applies(guardInput))
238
+ continue;
239
+ const res = await this.runGuard(guard, { ...guardInput, config: cfg }, guardBudgetMs(cfg.mode));
240
+ guardResults.push(res);
241
+ if (res.verdict === "block") {
242
+ notes.push(guardBlocked(res.id));
243
+ return this.denyGuardBlocked(receiptId, url, init, finalHost, verdictPath, notes, quote, budgetsAtDecision, leg1, rejected, guardResults, "danger");
244
+ }
245
+ if (res.verdict === "warn")
246
+ notes.push(guardWarn(res.id));
247
+ if (res.verdict === "unavailable") {
248
+ notes.push(guardUnavailable(res.id));
249
+ const disposition = resolveUnavailable(cfg, res);
250
+ if (disposition === "block") {
251
+ return this.denyGuardBlocked(receiptId, url, init, finalHost, verdictPath, notes, quote, budgetsAtDecision, leg1, rejected, guardResults, guardBlockReasonFor(res));
252
+ }
253
+ if (disposition === "warn")
254
+ notes.push(guardWarn(res.id));
255
+ }
256
+ }
257
+ verdictPath.push("dry_run");
258
+ if (dry) {
259
+ const receipt = this.baseReceipt(receiptId, url, init, finalHost, "dry_run", verdictPath, notes, quote, budgetsAtDecision, { rejectedQuotes: rejected, guards: guardResults, http: this.httpBlock(leg1) });
260
+ return this.finish(receipt, null);
261
+ }
262
+ verdictPath.push("approval");
263
+ let approvedBy = null;
264
+ if (microUsd(quote.amountUsd) > microUsd(policy.approval.thresholdUsd)) {
265
+ const appr = await this.runApproval(policy, finalHost, quote, guardResults, budgetsAtDecision, receiptId);
266
+ for (const n of appr.notes)
267
+ notes.push(n);
268
+ if (appr.kind === "denied") {
269
+ const receipt = this.baseReceipt(receiptId, url, init, finalHost, appr.outcome, verdictPath, notes, quote, budgetsAtDecision, { rejectedQuotes: rejected, guards: guardResults, http: this.httpBlock(leg1),
270
+ approval: { mode: policy.approval.mode, approvedBy: null } });
271
+ return this.finish(receipt, null);
272
+ }
273
+ approvedBy = appr.approvedBy;
274
+ }
275
+ const approvalBlock = approvedBy !== null ? { mode: policy.approval.mode, approvedBy } : null;
276
+ verdictPath.push("reserve");
277
+ const reservation = this.#cfg.budget.reserve({
278
+ holdId: receiptId,
279
+ amountUsd: quote.amountUsd,
280
+ host: finalHost,
281
+ caps,
282
+ });
283
+ if (!reservation.ok) {
284
+ return this.deny(receiptId, url, init, finalHost, verdictPath, notes, quote, budgetsAtDecision, `budget_exhausted:${reservation.which}`, [budgetExhausted(reservation.which)], "policy_denied", leg1, rejected, guardResults, approvalBlock);
285
+ }
286
+ verdictPath.push("pay");
287
+ return this.executePayment(receiptId, url, leg1.finalUrl, init, method, userHeaders, body, finalHost, quote, payer, policy, verdictPath, notes, rejected, guardResults, budgetsAtDecision, responseMode, approvalBlock);
288
+ }
289
+ async executePayment(receiptId, url, finalUrl, init, method, userHeaders, body, finalHost, quote, payer, policy, verdictPath, notes, rejected, guardResults, budgets, responseMode, approval) {
290
+ const deps = this.#deps;
291
+ const budget = this.#cfg.budget;
292
+ let proof;
293
+ try {
294
+ proof = await payer.buildPayment(quote, deps.signer, deps);
295
+ }
296
+ catch (err) {
297
+ budget.release(receiptId);
298
+ deps.log("payment.build_failed", { host: finalHost, message: scrub(err.message) });
299
+ const receipt = this.baseReceipt(receiptId, url, init, finalHost, "fetch_error", verdictPath, notes, quote, budgets, { rejectedQuotes: rejected, guards: guardResults, approval });
300
+ return this.finish(receipt, null);
301
+ }
302
+ budget.setHoldValidBefore(receiptId, proof.validBeforeTs);
303
+ const payerAddress = await deps.signer.address();
304
+ const io = this.transportIo();
305
+ const retry = await transportFetch(finalUrl, { method, headers: { ...userHeaders, ...proof.headers }, body }, policy, io, { followRedirects: false });
306
+ for (const n of retry.notes)
307
+ if (!notes.includes(n))
308
+ notes.push(n);
309
+ const cls = classifyTerminal(retry, quote);
310
+ if (cls.note)
311
+ notes.push(cls.note);
312
+ if (cls.holdDisposition === "settle") {
313
+ budget.settle(receiptId, cls.settledAmountUsd ?? quote.amountUsd);
314
+ }
315
+ if (policy.autoDeny.enabled) {
316
+ const strikeCls = strikeClassFor(cls.outcome);
317
+ if (strikeCls)
318
+ budget.recordStrike(finalHost, strikeCls);
319
+ }
320
+ let response = null;
321
+ if (cls.outcome === "paid_delivered" && retry.ok) {
322
+ const delivered = deliverBody(retry.rawBody ?? new Uint8Array(0), responseMode, {
323
+ fs: io.fs,
324
+ downloadPath: this.#cfg.ledger.downloadPath(receiptId),
325
+ });
326
+ if (delivered.mode === "inline" && delivered.truncated)
327
+ notes.push("body_truncated");
328
+ response = this.materialize(retry, delivered);
329
+ }
330
+ const receipt = this.baseReceipt(receiptId, url, init, finalHost, cls.outcome, verdictPath, notes, quote, budgets, {
331
+ rejectedQuotes: rejected,
332
+ guards: guardResults,
333
+ approval,
334
+ http: retry.ok ? this.httpBlock(retry) : null,
335
+ payment: {
336
+ payerAddress,
337
+ nonce: proof.nonce,
338
+ validBeforeTs: proof.validBeforeTs,
339
+ settledAmountUsd: cls.holdDisposition === "settle" ? cls.settledAmountUsd ?? quote.amountUsd : null,
340
+ txRef: cls.txRef,
341
+ settlementConfirmed: cls.settlementConfirmed,
342
+ },
343
+ });
344
+ return this.finish(receipt, response);
345
+ }
346
+ async runGuard(guard, input, budgetMs) {
347
+ const start = this.#deps.now();
348
+ const TIMEOUT = Symbol("guard_timeout");
349
+ try {
350
+ const res = await Promise.race([
351
+ guard.check(input, this.#deps),
352
+ this.#delay(budgetMs).then(() => TIMEOUT),
353
+ ]);
354
+ if (res === TIMEOUT)
355
+ return this.guardUnavailableResult(guard.id, start, "timeout");
356
+ return res;
357
+ }
358
+ catch (err) {
359
+ this.#deps.log("guard.crash", { id: guard.id, message: scrub(err.message) });
360
+ return this.guardUnavailableResult(guard.id, start, "crash");
361
+ }
362
+ }
363
+ guardUnavailableResult(id, start, reason) {
364
+ return { id, verdict: "unavailable", detail: { reason }, latencyMs: this.#deps.now() - start, costUsd: 0 };
365
+ }
366
+ async runApproval(policy, host, quote, guardResults, budgets, receiptId) {
367
+ const mode = policy.approval.mode;
368
+ if (mode === "deny") {
369
+ return { kind: "denied", outcome: "approval_denied", notes: ["approval_mode_deny"] };
370
+ }
371
+ const preNote = preApprovedNote(policy, host, quote);
372
+ if (preNote) {
373
+ return { kind: "approved", approvedBy: "config", notes: [preNote] };
374
+ }
375
+ if (mode === "queue") {
376
+ return this.queueApproval(host, quote, receiptId, []);
377
+ }
378
+ if (this.#deps.elicit === null) {
379
+ return this.elicitUnavailable(policy, host, quote, receiptId, "elicit_unsupported");
380
+ }
381
+ const req = {
382
+ host,
383
+ resource: quote.resource,
384
+ amountUsd: quote.amountUsd,
385
+ networkLabel: quote.network,
386
+ assetLabel: assetLabel(quote.asset),
387
+ guards: guardResults,
388
+ remainingBudgets: budgets,
389
+ };
390
+ let decision;
391
+ try {
392
+ decision = await Promise.race([
393
+ this.#deps.elicit(req),
394
+ this.#delay(APPROVAL_ELICIT_TIMEOUT_S * 1000).then(() => ({ __timeout: true })),
395
+ ]);
396
+ }
397
+ catch {
398
+ return this.elicitUnavailable(policy, host, quote, receiptId, "elicit_cancelled");
399
+ }
400
+ if ("__timeout" in decision) {
401
+ return { kind: "denied", outcome: "approval_timeout", notes: ["approval_timeout"] };
402
+ }
403
+ if (decision.cancelled) {
404
+ return this.elicitUnavailable(policy, host, quote, receiptId, "elicit_cancelled");
405
+ }
406
+ if (!decision.approved) {
407
+ return { kind: "denied", outcome: "approval_denied", notes: [] };
408
+ }
409
+ return { kind: "approved", approvedBy: "elicit", notes: [] };
410
+ }
411
+ elicitUnavailable(policy, host, quote, receiptId, cause) {
412
+ const fb = policy.approval.elicitFallback;
413
+ const fbNote = elicitUnsupportedFallback(fb);
414
+ if (fb === "queue") {
415
+ return this.queueApproval(host, quote, receiptId, [cause, fbNote]);
416
+ }
417
+ return { kind: "denied", outcome: "approval_denied", notes: [cause, fbNote] };
418
+ }
419
+ queueApproval(host, quote, receiptId, extraNotes) {
420
+ const grant = this.#cfg.budget.findGrant(host, quote.amountUsd);
421
+ if (grant) {
422
+ this.#cfg.budget.consumeGrant(grant.approvalId);
423
+ return { kind: "approved", approvedBy: "queue", notes: extraNotes };
424
+ }
425
+ this.#cfg.budget.addPendingApproval({
426
+ approvalId: receiptId,
427
+ host,
428
+ amountUsd: quote.amountUsd,
429
+ createdTs: this.#deps.now(),
430
+ resource: quote.resource,
431
+ });
432
+ return { kind: "denied", outcome: "approval_queued", notes: extraNotes };
433
+ }
434
+ liveGuardBudgetUsd(id) {
435
+ const pol = this.#cfg.policyProvider();
436
+ if (!pol.ok)
437
+ return 0;
438
+ return id === "trust"
439
+ ? pol.policy.guards.trust.dailyBudgetUsd
440
+ : pol.policy.guards.safety.dailyBudgetUsd;
441
+ }
442
+ makeGuardFetch(id, budgetUsd, baseUrl) {
443
+ const resolveBudget = typeof budgetUsd === "function" ? budgetUsd : () => budgetUsd;
444
+ return async (url, reqInit, opts) => {
445
+ const budget = resolveBudget();
446
+ if (budget <= 0 || opts?.dryRun)
447
+ return this.#deps.fetch(url, reqInit);
448
+ let host;
449
+ try {
450
+ host = new URL(url).hostname;
451
+ }
452
+ catch {
453
+ return this.#deps.fetch(url, reqInit);
454
+ }
455
+ const baseHost = baseUrl ? safeHostname(baseUrl) : null;
456
+ if (baseHost && host !== baseHost)
457
+ return this.#deps.fetch(url, reqInit);
458
+ return this.payGuardFetch(id, budget, host, url, reqInit);
459
+ };
460
+ }
461
+ async payGuardFetch(id, budgetUsd, host, url, reqInit) {
462
+ const deps = this.#deps;
463
+ const resp = await deps.fetch(url, { ...reqInit, redirect: "manual" });
464
+ if (resp.status !== 402)
465
+ return resp;
466
+ const bytes = new Uint8Array(await resp.arrayBuffer());
467
+ const challenge = parseChallenge(bytes);
468
+ const { quotes } = quoteWithRejections(challenge);
469
+ const quote = selectQuote(quotes);
470
+ const as402 = () => new Response(bytes, { status: 402, headers: resp.headers });
471
+ if (!quote)
472
+ return as402();
473
+ if (this.#cfg.testMode && quote.network === "base")
474
+ return as402();
475
+ const pol = this.#cfg.policyProvider();
476
+ if (!pol.ok)
477
+ return as402();
478
+ const caps = this.capsOf(pol.policy);
479
+ const receiptId = this.newReceiptId();
480
+ const day = utcDate(deps.now());
481
+ const reservation = this.#cfg.budget.reserve({
482
+ holdId: receiptId,
483
+ amountUsd: quote.amountUsd,
484
+ host,
485
+ caps,
486
+ extra: [{ key: guardDayKey(id, day), cap: budgetUsd, which: `guard:${id}` }],
487
+ });
488
+ if (!reservation.ok)
489
+ return as402();
490
+ let proof;
491
+ try {
492
+ proof = await this.#cfg.payers[0].buildPayment(quote, deps.signer, deps);
493
+ }
494
+ catch {
495
+ this.#cfg.budget.release(receiptId);
496
+ return as402();
497
+ }
498
+ this.#cfg.budget.setHoldValidBefore(receiptId, proof.validBeforeTs);
499
+ const payerAddress = await deps.signer.address();
500
+ const method = (reqInit.method ?? "GET").toUpperCase();
501
+ const userHeaders = stripReservedHeaders(reqInit.headers);
502
+ const bodyStr = typeof reqInit.body === "string" ? reqInit.body : null;
503
+ const retry = await deps.fetch(url, {
504
+ method,
505
+ headers: { ...userHeaders, ...proof.headers },
506
+ body: bodyStr ?? undefined,
507
+ redirect: "manual",
508
+ });
509
+ const retryBytes = new Uint8Array(await retry.clone().arrayBuffer());
510
+ const settlementHeader = readSettlementHeader(retry.headers);
511
+ const settlement = settlementHeader ? parseSettlementResponse(settlementHeader) : null;
512
+ const cls = classifyFromParts(retry.status, settlement, quote);
513
+ if (cls.holdDisposition === "settle") {
514
+ this.#cfg.budget.settle(receiptId, cls.settledAmountUsd ?? quote.amountUsd);
515
+ }
516
+ const budgets = this.#cfg.budget.remaining(caps, host);
517
+ const receipt = {
518
+ schema: "p3f.receipt.v1",
519
+ receiptId,
520
+ ts: deps.now(),
521
+ clientVersion: CLIENT_VERSION,
522
+ policyVersion: POLICY_VERSION,
523
+ test: this.#cfg.testMode,
524
+ url,
525
+ method,
526
+ host,
527
+ outcome: cls.outcome,
528
+ denyCode: null,
529
+ verdictPath: ["guard_spend"],
530
+ quote,
531
+ rejectedQuotes: null,
532
+ guards: [],
533
+ approval: null,
534
+ payment: {
535
+ payerAddress,
536
+ nonce: proof.nonce,
537
+ validBeforeTs: proof.validBeforeTs,
538
+ settledAmountUsd: cls.holdDisposition === "settle" ? cls.settledAmountUsd ?? quote.amountUsd : null,
539
+ txRef: cls.txRef,
540
+ settlementConfirmed: cls.settlementConfirmed,
541
+ },
542
+ budgets: {
543
+ dayRemainingUsd: budgets.dayRemainingUsd,
544
+ hostRemainingUsd: budgets.hostRemainingUsd,
545
+ totalRemainingUsd: budgets.totalRemainingUsd,
546
+ },
547
+ http: {
548
+ status: retry.status,
549
+ contentType: retry.headers.get("content-type"),
550
+ bodyBytes: retryBytes.length,
551
+ bodySha256: null,
552
+ truncated: false,
553
+ totalMs: 0,
554
+ },
555
+ notes: this.#cfg.testMode ? ["test_mode", ...(cls.note ? [cls.note] : [])] : cls.note ? [cls.note] : [],
556
+ };
557
+ this.#cfg.ledger.append(receipt);
558
+ return new Response(retryBytes, { status: retry.status, headers: retry.headers });
559
+ }
560
+ detectPayer(challenge) {
561
+ const p = this.#cfg.payers.find((x) => x.detects(challenge));
562
+ return p ?? this.#cfg.payers[0];
563
+ }
564
+ quoteAndReject(challenge, payer) {
565
+ if (payer.rail === "x402")
566
+ return quoteWithRejections(challenge);
567
+ return { quotes: payer.quotes(challenge), rejected: {} };
568
+ }
569
+ httpBlock(t) {
570
+ return {
571
+ status: t.status,
572
+ contentType: t.contentType,
573
+ bodyBytes: t.bodyBytes,
574
+ bodySha256: t.bodySha256,
575
+ truncated: t.hardCapped,
576
+ totalMs: t.totalMs,
577
+ };
578
+ }
579
+ materialize(t, delivered) {
580
+ const headers = t.headers ?? new Headers();
581
+ if (delivered.mode === "file") {
582
+ return new Response(null, { status: t.status ?? 200, headers });
583
+ }
584
+ return new Response(delivered.text, { status: t.status ?? 200, headers });
585
+ }
586
+ deny(receiptId, url, init, host, verdictPath, notes, quote, budgets, denyCode, denyNotes, outcome, leg1, rejected, guards, approval) {
587
+ for (const n of denyNotes)
588
+ if (!notes.includes(n))
589
+ notes.push(n);
590
+ const receipt = this.baseReceipt(receiptId, url, init, host, outcome, verdictPath, notes, quote, budgets, {
591
+ denyCode,
592
+ rejectedQuotes: rejected ?? null,
593
+ guards: guards ?? [],
594
+ http: this.httpBlock(leg1),
595
+ approval: approval ?? null,
596
+ });
597
+ return this.finish(receipt, null);
598
+ }
599
+ denyGuardBlocked(receiptId, url, init, host, verdictPath, notes, quote, budgets, leg1, rejected, guards, reason) {
600
+ const receipt = this.baseReceipt(receiptId, url, init, host, "guard_blocked", verdictPath, notes, quote, budgets, {
601
+ denyCode: "guard_blocked",
602
+ rejectedQuotes: rejected,
603
+ guards,
604
+ http: this.httpBlock(leg1),
605
+ guardBlockReason: reason,
606
+ });
607
+ return this.finish(receipt, null);
608
+ }
609
+ baseReceipt(receiptId, url, init, host, outcome, verdictPath, notes, quote, budgets, extra = {}) {
610
+ const method = (init.method ?? "GET").toUpperCase();
611
+ const finalNotes = [...notes];
612
+ for (const n of extra.notesExtra ?? [])
613
+ finalNotes.push(n);
614
+ return {
615
+ schema: "p3f.receipt.v1",
616
+ receiptId,
617
+ ts: this.#deps.now(),
618
+ clientVersion: CLIENT_VERSION,
619
+ policyVersion: POLICY_VERSION,
620
+ test: this.#cfg.testMode,
621
+ url,
622
+ method,
623
+ host,
624
+ outcome,
625
+ denyCode: extra.denyCode ?? null,
626
+ guardBlockReason: extra.guardBlockReason ?? null,
627
+ verdictPath: [...verdictPath],
628
+ quote,
629
+ rejectedQuotes: extra.rejectedQuotes ?? null,
630
+ guards: extra.guards ?? [],
631
+ approval: extra.approval ?? null,
632
+ payment: extra.payment ?? null,
633
+ budgets: {
634
+ dayRemainingUsd: budgets.dayRemainingUsd,
635
+ hostRemainingUsd: budgets.hostRemainingUsd,
636
+ totalRemainingUsd: budgets.totalRemainingUsd,
637
+ },
638
+ http: extra.http ?? null,
639
+ notes: finalNotes,
640
+ };
641
+ }
642
+ finish(receipt, response) {
643
+ this.#cfg.ledger.append(receipt);
644
+ return { response, receipt };
645
+ }
646
+ decisionFromReceipt(r) {
647
+ const decision = r.outcome === "dry_run" ? "would_pay" : r.outcome === "free" ? "free" : "would_deny";
648
+ return {
649
+ outcome: r.outcome,
650
+ denyCode: r.denyCode,
651
+ decision,
652
+ quote: r.quote,
653
+ rejectedQuotes: r.rejectedQuotes,
654
+ guards: r.guards,
655
+ remainingBudgets: {
656
+ dayRemainingUsd: r.budgets.dayRemainingUsd,
657
+ hostRemainingUsd: r.budgets.hostRemainingUsd,
658
+ totalRemainingUsd: r.budgets.totalRemainingUsd,
659
+ },
660
+ notes: r.notes,
661
+ };
662
+ }
663
+ }
664
+ export function classifyTerminal(retry, quote) {
665
+ if (!retry.ok) {
666
+ if (retry.error === "private_target_blocked" || retry.error === "insecure_redirect") {
667
+ return { outcome: "fetch_error", holdDisposition: "keep", settlementConfirmed: false, settledAmountUsd: null, txRef: null, note: null };
668
+ }
669
+ return { outcome: "unknown_settlement", holdDisposition: "keep", settlementConfirmed: false, settledAmountUsd: null, txRef: null, note: null };
670
+ }
671
+ const settlement = retry.headers
672
+ ? parseSettlementFromHeaders(retry.headers)
673
+ : null;
674
+ return classifyFromParts(retry.status ?? 0, settlement, quote);
675
+ }
676
+ function readSettlementHeader(headers) {
677
+ return headers.get(PAYMENT_RESPONSE_HEADER) ?? headers.get(X_PAYMENT_RESPONSE_HEADER);
678
+ }
679
+ function parseSettlementFromHeaders(headers) {
680
+ const v = readSettlementHeader(headers);
681
+ if (!v)
682
+ return null;
683
+ return parseSettlementResponse(v);
684
+ }
685
+ export function classifyFromParts(status, settlement, _quote) {
686
+ const confirmed = settlement?.success === true;
687
+ const txRef = settlement?.transaction ?? null;
688
+ const is2xx = status >= 200 && status < 300;
689
+ if (is2xx) {
690
+ if (confirmed) {
691
+ return { outcome: "paid_delivered", holdDisposition: "settle", settlementConfirmed: true, settledAmountUsd: null, txRef, note: null };
692
+ }
693
+ return { outcome: "paid_delivered", holdDisposition: "settle", settlementConfirmed: false, settledAmountUsd: null, txRef, note: "settlement_unconfirmed" };
694
+ }
695
+ if (confirmed) {
696
+ return { outcome: "paid_not_delivered", holdDisposition: "settle", settlementConfirmed: true, settledAmountUsd: null, txRef, note: null };
697
+ }
698
+ if (status >= 500) {
699
+ return { outcome: "unknown_settlement", holdDisposition: "keep", settlementConfirmed: false, settledAmountUsd: null, txRef: null, note: null };
700
+ }
701
+ return { outcome: "payment_rejected", holdDisposition: "keep", settlementConfirmed: false, settledAmountUsd: null, txRef: null, note: null };
702
+ }
703
+ function strikeClassFor(o) {
704
+ if (o === "paid_not_delivered" || o === "payment_rejected")
705
+ return "confirmed";
706
+ if (o === "unknown_settlement")
707
+ return "soft";
708
+ return null;
709
+ }
710
+ function isDegradedScreen(res) {
711
+ return res.verdict === "unavailable" && res.detail?.reason === "degraded_screen";
712
+ }
713
+ function resolveUnavailable(cfg, res) {
714
+ if (cfg.mode !== "enforce")
715
+ return "proceed";
716
+ if (isDegradedScreen(res) && "onDegraded" in cfg)
717
+ return cfg.onDegraded;
718
+ return cfg.onUnavailable;
719
+ }
720
+ function guardBlockReasonFor(res) {
721
+ if (res.verdict === "block")
722
+ return "danger";
723
+ const reason = typeof res.detail?.reason === "string" ? res.detail.reason : "";
724
+ if (reason === "degraded_screen")
725
+ return "degraded";
726
+ if (reason === "timeout")
727
+ return "timeout";
728
+ return "unavailable";
729
+ }
730
+ function preApprovedNote(policy, host, quote) {
731
+ const a = policy.approval;
732
+ if (matchesAnyHost(host, a.preApprovedHosts))
733
+ return preapproved("host");
734
+ if (a.preApprovedUpToUsd !== null && microUsd(quote.amountUsd) <= microUsd(a.preApprovedUpToUsd)) {
735
+ return preapproved("cap");
736
+ }
737
+ return null;
738
+ }
739
+ function microUsd(x) {
740
+ return Number.isFinite(x) ? Math.round(x * 1_000_000) : Number.POSITIVE_INFINITY;
741
+ }
742
+ export function stripReservedHeaders(headers) {
743
+ const reserved = new Set([
744
+ X_PAYMENT_HEADER.toLowerCase(),
745
+ X_PAYMENT_RESPONSE_HEADER.toLowerCase(),
746
+ INTEGRATION_HEADER.toLowerCase(),
747
+ ]);
748
+ const out = {};
749
+ if (!headers)
750
+ return out;
751
+ const h = new Headers(headers);
752
+ h.forEach((value, key) => {
753
+ if (!reserved.has(key.toLowerCase()))
754
+ out[key] = value;
755
+ });
756
+ return out;
757
+ }
758
+ function guardUrl(fullUrl) {
759
+ if (GUARD_SEND_QUERY)
760
+ return fullUrl;
761
+ try {
762
+ const u = new URL(fullUrl);
763
+ return `${u.origin}${u.pathname}`;
764
+ }
765
+ catch {
766
+ return fullUrl;
767
+ }
768
+ }
769
+ function assetLabel(asset) {
770
+ const known = KNOWN_ASSETS.find((a) => a.address.toLowerCase() === asset.toLowerCase());
771
+ return known?.label ?? asset;
772
+ }
773
+ function safeHostname(url) {
774
+ try {
775
+ return new URL(url).hostname;
776
+ }
777
+ catch {
778
+ return "";
779
+ }
780
+ }
781
+ function tallyNotes(rejected) {
782
+ const out = [];
783
+ for (const reason of Object.keys(rejected)) {
784
+ if (reason === "unsupported_scheme_upto")
785
+ out.push("unsupported_scheme_upto");
786
+ else if (reason === "unknown_asset")
787
+ out.push("unknown_asset");
788
+ else if (reason === "unsupported_network")
789
+ out.push("unsupported_network");
790
+ }
791
+ return out;
792
+ }
793
+ function scrub(s) {
794
+ return s.replace(/0x[0-9a-fA-F]{40,}/g, "0x<redacted>");
795
+ }