x402-safe-fetch 0.10.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
@@ -101,6 +101,7 @@ When a seller offers both, the exact option is paid. The preflight and the budge
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
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
 
@@ -140,6 +141,13 @@ With `shareOutcomes: true`, safe-fetch tells x402 Doctor what happened after it
140
141
 
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.
142
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
+
143
151
  ## Signed verdicts
144
152
 
145
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
@@ -70,6 +70,8 @@ export interface SafeFetchOptions {
70
70
  onDiagnosis?: (report: Diagnosis, info: { url: string; method: string; status: number | null; error: Error | null }) => void;
71
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;
package/index.js CHANGED
@@ -20,6 +20,10 @@
20
20
  // preflight's reason code, so the seller learns why nobody pays (Doctor shows
21
21
  // sellers the counts per reason, never who). Off by default; no keys, amounts
22
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.
23
27
  // Every preflight must carry Doctor's signed receipt for exactly this request
24
28
  // (endpoint, method, budget, network), checked against Doctor's pinned signer:
25
29
  // a missing, changed or forged verdict is never a payment (receipt.js).
@@ -37,7 +41,7 @@ export { verifyReceipt, recoverSigner, canonicalJson, inputHash, certMessage, DO
37
41
  export const DOCTOR_URL = "https://x402-doctor.fizzl.eu";
38
42
  export const PREFLIGHT_CAP = "$0.002"; // the preflight costs $0.001; never more than twice that
39
43
  export const DIAGNOSE_CAP = "$0.02"; // the diagnosis costs $0.01; never more than twice that
40
- export const VERSION = "0.10.0";
44
+ export const VERSION = "0.11.0";
41
45
  // Doctor sees which calls come from this package (in its usage counts), nothing more.
42
46
  const DOCTOR_HEADERS = { accept: "application/json", "user-agent": `x402-safe-fetch/${VERSION}` };
43
47
  export const BASE = "eip155:8453";
@@ -209,6 +213,17 @@ function requestOf(input, init) {
209
213
  return { url: String(input), method: String(init?.method ?? "GET").toUpperCase() };
210
214
  }
211
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
+
212
227
  /**
213
228
  * @param {object} options
214
229
  * @param {(client: x402Client) => unknown} options.register registers your payment schemes on a client
@@ -226,6 +241,7 @@ function requestOf(input, init) {
226
241
  * @param {boolean} [options.diagnoseOnFailure] when a payment still fails, buy a $0.01 Doctor diagnosis of why (default false)
227
242
  * @param {(report: object, info: {url: string, method: string, status: number|null, error: Error|null}) => void} [options.onDiagnosis]
228
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)
229
245
  * @param {(cap: string) => typeof fetch} [options.createPayingFetch] advanced/testing: a paying fetch capped at `cap`
230
246
  * @param {() => number} [options.now]
231
247
  */
@@ -244,6 +260,7 @@ export function createSafeFetch({
244
260
  diagnoseOnFailure = false,
245
261
  onDiagnosis,
246
262
  shareOutcomes = false,
263
+ tellSellers = false,
247
264
  fetch: baseFetch = globalThis.fetch,
248
265
  createPayingFetch,
249
266
  now = Date.now,
@@ -357,9 +374,41 @@ export function createSafeFetch({
357
374
  const outcome = res.ok ? "paid_ok" : res.status === 402 ? "paid_failed" : res.status >= 400 ? "paid_error" : null;
358
375
  if (outcome) sendOutcome(url, method, preflight, { outcome, status: res.status });
359
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
+ }
360
408
  // Not paid after a preflight: "refused", with the reason that stopped it (the first reason at the verdict's level).
361
409
  function reportRefusal(url, method, preflight) {
362
410
  sendOutcome(url, method, preflight, { outcome: "refused", reason: refusalReason(preflight), source: "doctor" });
411
+ tellSeller(url, preflight);
363
412
  }
364
413
 
365
414
  async function payAndCheck(url, init, method) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-safe-fetch",
3
- "version": "0.10.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": {