x402-safe-fetch 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -100,7 +100,8 @@ When a seller offers both, the exact option is paid. The preflight and the budge
100
100
  | `verifyReceipts` | `"require"` | Check Doctor's signed receipt on every preflight; `"off"` skips it |
101
101
  | `doctorSigners` | Doctor's published signer | Accepted signer addresses (for a self-hosted Doctor) |
102
102
  | `diagnoseOnFailure` | `false` | When a payment still fails, buy a $0.01 Doctor diagnosis of why (see below) |
103
- | `shareOutcomes` | `false` | After paying, tell Doctor whether the payment worked, so its later preflights learn from it (see below) |
103
+ | `shareOutcomes` | `false` | After paying, tell Doctor whether the payment worked, so its later preflights learn from it; when not paying, tell it why (see below) |
104
+ | `tellSellers` | `false` | When not paying, tell the seller itself why, if its 402 takes refusals (draft refusal extension, see below) |
104
105
  | `onDiagnosis` | | `(report, { url, method, status, error }) => void`, e.g. for logging or alerting |
105
106
  | `authority` | the Fizzl payout wallet | Wallet whose certificates also make a signer trusted (key rotation); `null` accepts only `doctorSigners` |
106
107
 
@@ -138,6 +139,15 @@ With `shareOutcomes: true`, safe-fetch tells x402 Doctor what happened after it
138
139
  - A report only counts with a real, paid preflight for that endpoint, once per preflight, so reports cannot be made up for free.
139
140
  - It is sent after the answer and never delays or changes it. Off by default.
140
141
 
142
+ When safe-fetch does **not** pay (a `no_go`, or a `caution` that `onCaution` stops), it reports `refused` with the reason code that stopped it (for example `payto_changed`, `price_changed` or `payee_mismatch`). That is the seller's signal back: Doctor shows sellers how often agents refused to pay their endpoint and why (in the free web check and `GET https://x402-doctor.fizzl.eu/api/v1/refusals?url=…`), as counts only, never who.
143
+
144
+ With `tellSellers: true`, a refusal also goes straight to the seller, when its 402 names a refusal url on its own site (the draft refusal extension, [x402-foundation/x402#4087](https://github.com/x402-foundation/x402/issues/4087)). safe-fetch posts the ready report from Doctor's preflight:
145
+ - the reason code (for example `screening_hit` or `price_above_limit`);
146
+ - the sha256 of the refused offer;
147
+ - the endpoint without its query.
148
+
149
+ No keys, no amounts from your wallet, nothing about you. It goes only to the endpoint's own site, with redirects refused, at most once a day per endpoint and reason, and never delays the error you get. It works with or without `shareOutcomes`.
150
+
141
151
  ## Signed verdicts
142
152
 
143
153
  x402 Doctor signs every paid preflight (EIP-191 over canonical JSON, with the request inside the signed body; see [Signed verdicts](https://github.com/Fizzl13/x402-doctor#signed-verdicts)). safe-fetch checks that signature before it acts on a verdict:
package/index.d.ts CHANGED
@@ -68,8 +68,10 @@ export interface SafeFetchOptions {
68
68
  /** When a payment still fails (402 again, or paying throws), buy a $0.01 Doctor diagnosis of why. Default false. */
69
69
  diagnoseOnFailure?: boolean;
70
70
  onDiagnosis?: (report: Diagnosis, info: { url: string; method: string; status: number | null; error: Error | null }) => void;
71
- /** After paying, tell Doctor whether the payment worked (with the signed preflight as proof), so later preflights learn from it. Default false. */
71
+ /** After paying, tell Doctor whether the payment worked (with the signed preflight as proof), so later preflights learn from it; when it does not pay (no_go, or a caution it stops at), report "refused" with the reason code, so the seller learns why. Default false. */
72
72
  shareOutcomes?: boolean;
73
+ /** When not paying, tell the seller why if its 402 names a refusal url on its own site (draft x402-foundation/x402#4087). Default false. */
74
+ tellSellers?: boolean;
73
75
  fetch?: typeof globalThis.fetch;
74
76
  /** Advanced/testing: a paying fetch capped at `cap` (e.g. "$0.05"). */
75
77
  createPayingFetch?: (cap: string) => typeof globalThis.fetch;
@@ -91,3 +93,6 @@ export declare function recoverSigner(message: string, signature: string): strin
91
93
  export declare const AUTHORITY: string;
92
94
  export declare function certMessage(cert: { service: string; signer: string; valid_from: string }): string;
93
95
  export declare function verifyReceipt(body: unknown, options?: { signers?: string[]; route?: string; input?: unknown; authority?: string | null; service?: string }): { valid: boolean; signer?: string; reason?: string };
96
+
97
+ /** The reason code reported with a refused outcome: the preflight's first reason at its verdict's level. */
98
+ export function refusalReason(preflight: { verdict?: string; reasons?: { level?: string; code?: string }[] }): string;
package/index.js CHANGED
@@ -15,8 +15,15 @@
15
15
  // to fix it: onDiagnosis(report), diagnosisOf(response) or error.diagnosis.
16
16
  // With shareOutcomes, after paying it tells Doctor what happened (paid_ok,
17
17
  // paid_failed, paid_error), with the signed preflight as proof, so later
18
- // preflights for that endpoint learn from what agents ran into. Off by
19
- // default; no keys, amounts or response content are sent.
18
+ // preflights for that endpoint learn from what agents ran into. When it does
19
+ // not pay (no_go, or a caution it stops at), it reports "refused" with the
20
+ // preflight's reason code, so the seller learns why nobody pays (Doctor shows
21
+ // sellers the counts per reason, never who). Off by default; no keys, amounts
22
+ // or response content are sent.
23
+ // With tellSellers, a refusal also goes to the seller itself when its 402 names a refusal url on its own site (the
24
+ // draft refusal extension, x402-foundation/x402#4087): the ready report from Doctor's preflight (reason code and the
25
+ // hash of the refused offer; no query, no keys, nothing about the buyer), once a day per endpoint and reason. Off by
26
+ // default.
20
27
  // Every preflight must carry Doctor's signed receipt for exactly this request
21
28
  // (endpoint, method, budget, network), checked against Doctor's pinned signer:
22
29
  // a missing, changed or forged verdict is never a payment (receipt.js).
@@ -34,7 +41,7 @@ export { verifyReceipt, recoverSigner, canonicalJson, inputHash, certMessage, DO
34
41
  export const DOCTOR_URL = "https://x402-doctor.fizzl.eu";
35
42
  export const PREFLIGHT_CAP = "$0.002"; // the preflight costs $0.001; never more than twice that
36
43
  export const DIAGNOSE_CAP = "$0.02"; // the diagnosis costs $0.01; never more than twice that
37
- export const VERSION = "0.9.0";
44
+ export const VERSION = "0.11.0";
38
45
  // Doctor sees which calls come from this package (in its usage counts), nothing more.
39
46
  const DOCTOR_HEADERS = { accept: "application/json", "user-agent": `x402-safe-fetch/${VERSION}` };
40
47
  export const BASE = "eip155:8453";
@@ -124,6 +131,15 @@ export function payableOption(accepts, network, maxUsd) {
124
131
  return on.find((a) => a?.scheme === "exact") ?? on.find((a) => a?.scheme === "auth-capture") ?? on[0] ?? null;
125
132
  }
126
133
 
134
+ // The code that stopped the payment: the preflight's first reason at its verdict's level (no_go, then caution), as
135
+ // Doctor accepts it (a-z, 0-9, _); the verdict itself when there is none.
136
+ export function refusalReason(preflight) {
137
+ const reasons = Array.isArray(preflight?.reasons) ? preflight.reasons : [];
138
+ const pick = reasons.find((r) => r?.level === preflight?.verdict) || reasons.find((r) => r?.level === "no_go" || r?.level === "caution");
139
+ const code = String(pick?.code || preflight?.verdict || "refused").toLowerCase().replace(/[^a-z0-9_]/g, "_").slice(0, 48);
140
+ return /^[a-z][a-z0-9_]{1,47}$/.test(code) ? code : "refused";
141
+ }
142
+
127
143
  export class SafePayError extends Error {
128
144
  /** code: "no_go" | "caution" | "preflight_failed" | "bad_receipt" | "no_option" */
129
145
  constructor(message, { code, preflight = null, url = null } = {}) {
@@ -197,6 +213,17 @@ function requestOf(input, init) {
197
213
  return { url: String(input), method: String(init?.method ?? "GET").toUpperCase() };
198
214
  }
199
215
 
216
+ // Same site: the same host, or the same last two host labels except on shared hosting and two-label country
217
+ // suffixes, where only the exact host counts (as x402 Doctor's lib/refusal.js).
218
+ const SHARED_HOSTING = new Set(["onrender.com", "vercel.app", "netlify.app", "herokuapp.com", "workers.dev", "pages.dev", "github.io", "fly.dev", "railway.app", "replit.app", "repl.co", "web.app", "firebaseapp.com", "appspot.com", "deno.dev", "azurewebsites.net", "cloudfront.net", "amazonaws.com", "ngrok-free.app", "ngrok.io", "trycloudflare.com", "glitch.me", "koyeb.app", "co.uk", "org.uk", "com.au", "co.jp", "com.br", "co.nz", "co.za", "com.cn", "co.in"]);
219
+ function sameSite(a, b) {
220
+ const x = a.toLowerCase();
221
+ const y = b.toLowerCase();
222
+ if (x === y) return true;
223
+ const base = (h) => h.split(".").slice(-2).join(".");
224
+ return base(x) === base(y) && !SHARED_HOSTING.has(base(x));
225
+ }
226
+
200
227
  /**
201
228
  * @param {object} options
202
229
  * @param {(client: x402Client) => unknown} options.register registers your payment schemes on a client
@@ -214,6 +241,7 @@ function requestOf(input, init) {
214
241
  * @param {boolean} [options.diagnoseOnFailure] when a payment still fails, buy a $0.01 Doctor diagnosis of why (default false)
215
242
  * @param {(report: object, info: {url: string, method: string, status: number|null, error: Error|null}) => void} [options.onDiagnosis]
216
243
  * @param {boolean} [options.shareOutcomes] after paying, tell Doctor whether the payment worked (default false)
244
+ * @param {boolean} [options.tellSellers] when not paying, tell the seller why if its 402 takes refusals (default false)
217
245
  * @param {(cap: string) => typeof fetch} [options.createPayingFetch] advanced/testing: a paying fetch capped at `cap`
218
246
  * @param {() => number} [options.now]
219
247
  */
@@ -232,6 +260,7 @@ export function createSafeFetch({
232
260
  diagnoseOnFailure = false,
233
261
  onDiagnosis,
234
262
  shareOutcomes = false,
263
+ tellSellers = false,
235
264
  fetch: baseFetch = globalThis.fetch,
236
265
  createPayingFetch,
237
266
  now = Date.now,
@@ -326,10 +355,8 @@ export function createSafeFetch({
326
355
 
327
356
  // One outcome report per paid preflight: Doctor counts it once anyway.
328
357
  const reported = new Set();
329
- function reportOutcome(url, method, preflight, res) {
358
+ function sendOutcome(url, method, preflight, report) {
330
359
  if (!shareOutcomes || !preflight?.receipt?.request_id || reported.has(preflight.receipt.request_id)) return;
331
- const outcome = res.ok ? "paid_ok" : res.status === 402 ? "paid_failed" : res.status >= 400 ? "paid_error" : null;
332
- if (!outcome) return;
333
360
  reported.add(preflight.receipt.request_id);
334
361
  if (reported.size > 1000) reported.delete(reported.values().next().value);
335
362
  const query = Object.fromEntries(new URL(preflightUrl(url, { method, maxUsd: budget, network: payNetwork, doctorUrl })).searchParams);
@@ -338,11 +365,51 @@ export function createSafeFetch({
338
365
  .then(() => baseFetch(`${doctorUrl.replace(/\/$/, "")}/api/v1/outcome`, {
339
366
  method: "POST",
340
367
  headers: { ...DOCTOR_HEADERS, "content-type": "application/json" },
341
- body: JSON.stringify({ outcome, status: res.status, preflight, query }),
368
+ body: JSON.stringify({ ...report, preflight, query }),
342
369
  }))
343
370
  .then((r) => r?.body?.cancel?.())
344
371
  .catch(() => {});
345
372
  }
373
+ function reportOutcome(url, method, preflight, res) {
374
+ const outcome = res.ok ? "paid_ok" : res.status === 402 ? "paid_failed" : res.status >= 400 ? "paid_error" : null;
375
+ if (outcome) sendOutcome(url, method, preflight, { outcome, status: res.status });
376
+ }
377
+ // The seller's own refusal url (tellSellers): only on the endpoint's own site, only a report about this endpoint,
378
+ // redirects refused, once a day per endpoint and reason, fire and forget.
379
+ const toldSellers = new Map();
380
+ function tellSeller(url, preflight) {
381
+ const refusal = preflight?.refusal;
382
+ if (!tellSellers || !refusal?.url || !refusal.report) return;
383
+ let target;
384
+ let endpoint;
385
+ try {
386
+ target = new URL(refusal.url);
387
+ endpoint = new URL(url);
388
+ } catch {
389
+ return;
390
+ }
391
+ if (target.protocol !== "https:" && !["localhost", "127.0.0.1"].includes(target.hostname)) return;
392
+ if (!sameSite(target.hostname, endpoint.hostname)) return;
393
+ if (refusal.report.resource !== `${endpoint.origin}${endpoint.pathname}`) return;
394
+ const key = `${refusal.report.resource} ${refusal.report.reason}`;
395
+ if (now() - (toldSellers.get(key) ?? -Infinity) < 24 * 60 * 60 * 1000) return;
396
+ toldSellers.set(key, now());
397
+ if (toldSellers.size > 1000) toldSellers.delete(toldSellers.keys().next().value);
398
+ Promise.resolve()
399
+ .then(() => baseFetch(target.href, {
400
+ method: "POST",
401
+ headers: { "content-type": "application/json" },
402
+ body: JSON.stringify({ ...refusal.report, by: `x402-safe-fetch/${VERSION}` }),
403
+ redirect: "error",
404
+ }))
405
+ .then((r) => r?.body?.cancel?.())
406
+ .catch(() => {});
407
+ }
408
+ // Not paid after a preflight: "refused", with the reason that stopped it (the first reason at the verdict's level).
409
+ function reportRefusal(url, method, preflight) {
410
+ sendOutcome(url, method, preflight, { outcome: "refused", reason: refusalReason(preflight), source: "doctor" });
411
+ tellSeller(url, preflight);
412
+ }
346
413
 
347
414
  async function payAndCheck(url, init, method) {
348
415
  if (!diagnoseOnFailure) return payEndpoint(url, init);
@@ -386,10 +453,16 @@ export function createSafeFetch({
386
453
  };
387
454
  onPreflight?.(preflight, { url, method, cached });
388
455
  const why = preflight.summary || preflight.verdict;
389
- if (preflight.verdict === "no_go") throw new SafePayError(`not paid: ${why}`, { code: "no_go", preflight, url });
456
+ if (preflight.verdict === "no_go") {
457
+ reportRefusal(url, method, preflight);
458
+ throw new SafePayError(`not paid: ${why}`, { code: "no_go", preflight, url });
459
+ }
390
460
  if (preflight.verdict === "caution") {
391
461
  const ok = onCaution === "pay" || (typeof onCaution === "function" && (await onCaution(preflight)) === true);
392
- if (!ok) throw new SafePayError(`not paid (caution): ${why}`, { code: "caution", preflight, url });
462
+ if (!ok) {
463
+ reportRefusal(url, method, preflight);
464
+ throw new SafePayError(`not paid (caution): ${why}`, { code: "caution", preflight, url });
465
+ }
393
466
  } else if (preflight.verdict !== "go") {
394
467
  throw new SafePayError(`not paid: unknown verdict ${preflight.verdict}`, { code: "preflight_failed", preflight, url });
395
468
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-safe-fetch",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "A fetch for AI agents that checks an unknown x402 endpoint ($0.001 x402 Doctor preflight) before paying it, and never pays above your budget.",
5
5
  "type": "module",
6
6
  "exports": {