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 +8 -0
- package/index.d.ts +2 -0
- package/index.js +50 -1
- package/package.json +1 -1
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.
|
|
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.
|
|
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": {
|