x402-safe-fetch 0.11.0 → 0.12.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 +4 -3
- package/index.d.ts +7 -1
- package/index.js +22 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ A free endpoint is answered as is. For a paid one (HTTP 402), your agent first p
|
|
|
6
6
|
|
|
7
7
|
| Preflight | What `safeFetch` does |
|
|
8
8
|
|---|---|
|
|
9
|
-
| `go` | Pays the endpoint, never more than `maxUsd` |
|
|
9
|
+
| `go` | Pays the endpoint, never more than `maxUsd`; with `onIncomplete: "stop"`, not when Doctor says a check couldn't run (`checks_complete: false`) |
|
|
10
10
|
| `caution` | `onCaution`: `"stop"` (default, throws), `"pay"`, or your own function (e.g. ask the user) |
|
|
11
11
|
| `no_go` | Throws `SafePayError`; the endpoint is not paid |
|
|
12
12
|
|
|
@@ -94,6 +94,7 @@ When a seller offers both, the exact option is paid. The preflight and the budge
|
|
|
94
94
|
| `network` | `"base"` | `"base"`, `"solana"`, `"algorand"` (USDC), `"xrpl"` (RLUSD), `"stellar"` (USDC), `"xlayer"` (USD₮0 or USDC), their `-testnet` forms, or a CAIP-2 id; the preflight checks the option on this network |
|
|
95
95
|
| `maxUsd` | `0.05` | Budget per endpoint call; the payment is capped at it and the preflight says `no_go` above it |
|
|
96
96
|
| `onCaution` | `"stop"` | `"stop"`, `"pay"` or `async (preflight) => boolean` |
|
|
97
|
+
| `onIncomplete` | `"pay"` | A `go` whose `checks_complete` is `false` (a check that applies couldn't run this time, named in `not_checked`): `"pay"`, `"stop"` (throws code `incomplete`; not reported to the seller as a refusal) or `async (preflight) => boolean`. Such a verdict is reused for at most 10 minutes |
|
|
97
98
|
| `trusted` | `[]` | Hosts paid without a preflight |
|
|
98
99
|
| `onPreflight` | | `(preflight, { url, method, cached }) => void`, e.g. for logging |
|
|
99
100
|
| `cacheMs` | 1 hour | How long a verdict is reused per method and URL while the 402 offers the same thing (at most 10 minutes when the offer can't be read); any change in price, payout address, token or network means a new preflight |
|
|
@@ -142,11 +143,11 @@ With `shareOutcomes: true`, safe-fetch tells x402 Doctor what happened after it
|
|
|
142
143
|
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
|
|
|
144
145
|
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 `
|
|
146
|
+
- the reason code (for example `price_above_limit`, `network_unsupported` or `trust_low`);
|
|
146
147
|
- the sha256 of the refused offer;
|
|
147
148
|
- the endpoint without its query.
|
|
148
149
|
|
|
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
|
+
No keys, no amounts from your wallet, nothing about you, and no `by` naming safe-fetch, so buyers aren't grouped by the client they use. A `screening_hit` (sanctioned payout address) isn't sent: the draft takes it only signed, and safe-fetch holds no key to sign with. 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
151
|
|
|
151
152
|
## Signed verdicts
|
|
152
153
|
|
package/index.d.ts
CHANGED
|
@@ -26,6 +26,10 @@ export interface Preflight {
|
|
|
26
26
|
summary?: string;
|
|
27
27
|
reasons?: Array<{ level: string; code?: string; message: string }>;
|
|
28
28
|
signals?: Record<string, unknown>;
|
|
29
|
+
checked?: Array<{ check: string; what: string }>;
|
|
30
|
+
not_checked?: Array<{ check: string; because: "unavailable" | "no_data" | "not_sent" | "out_of_scope"; why: string; send?: string }>;
|
|
31
|
+
/** false when a check that applies couldn't run this time */
|
|
32
|
+
checks_complete?: boolean;
|
|
29
33
|
[key: string]: unknown;
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -37,7 +41,7 @@ export interface Diagnosis {
|
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export declare class SafePayError extends Error {
|
|
40
|
-
code: "no_go" | "caution" | "preflight_failed" | "bad_receipt" | "no_option";
|
|
44
|
+
code: "no_go" | "caution" | "incomplete" | "preflight_failed" | "bad_receipt" | "no_option";
|
|
41
45
|
preflight: Preflight | null;
|
|
42
46
|
url: string | null;
|
|
43
47
|
/** Set when diagnoseOnFailure diagnosed a payment that failed. */
|
|
@@ -53,6 +57,8 @@ export interface SafeFetchOptions {
|
|
|
53
57
|
maxUsd?: number | string;
|
|
54
58
|
/** What to do on a caution verdict. Default "stop". */
|
|
55
59
|
onCaution?: "stop" | "pay" | ((preflight: Preflight) => boolean | Promise<boolean>);
|
|
60
|
+
/** What to do on a "go" whose checks_complete is false (a check that applies couldn't run). Default "pay"; "stop" throws code "incomplete". */
|
|
61
|
+
onIncomplete?: "pay" | "stop" | ((preflight: Preflight) => boolean | Promise<boolean>);
|
|
56
62
|
/** Hosts you already trust: paid without a preflight. */
|
|
57
63
|
trusted?: string[];
|
|
58
64
|
onPreflight?: (preflight: Preflight, info: { url: string; method: string; cached: boolean }) => void;
|
package/index.js
CHANGED
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
// or response content are sent.
|
|
23
23
|
// With tellSellers, a refusal also goes to the seller itself when its 402 names a refusal url on its own site (the
|
|
24
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
|
|
26
|
-
// default.
|
|
25
|
+
// hash of the refused offer; no query, no keys, nothing about the buyer or this client), once a day per endpoint and
|
|
26
|
+
// reason. A screening_hit isn't sent: the draft takes it only signed. Off by default.
|
|
27
27
|
// Every preflight must carry Doctor's signed receipt for exactly this request
|
|
28
28
|
// (endpoint, method, budget, network), checked against Doctor's pinned signer:
|
|
29
29
|
// a missing, changed or forged verdict is never a payment (receipt.js).
|
|
@@ -41,7 +41,7 @@ export { verifyReceipt, recoverSigner, canonicalJson, inputHash, certMessage, DO
|
|
|
41
41
|
export const DOCTOR_URL = "https://x402-doctor.fizzl.eu";
|
|
42
42
|
export const PREFLIGHT_CAP = "$0.002"; // the preflight costs $0.001; never more than twice that
|
|
43
43
|
export const DIAGNOSE_CAP = "$0.02"; // the diagnosis costs $0.01; never more than twice that
|
|
44
|
-
export const VERSION = "0.
|
|
44
|
+
export const VERSION = "0.12.0";
|
|
45
45
|
// Doctor sees which calls come from this package (in its usage counts), nothing more.
|
|
46
46
|
const DOCTOR_HEADERS = { accept: "application/json", "user-agent": `x402-safe-fetch/${VERSION}` };
|
|
47
47
|
export const BASE = "eip155:8453";
|
|
@@ -230,6 +230,8 @@ function sameSite(a, b) {
|
|
|
230
230
|
* @param {string} [options.network] the network you pay on: "base", "solana", "algorand", "xrpl", "stellar" or a CAIP-2 id (default Base)
|
|
231
231
|
* @param {number|string} [options.maxUsd] budget per endpoint call (default 0.05)
|
|
232
232
|
* @param {"stop"|"pay"|((preflight: object) => boolean|Promise<boolean>)} [options.onCaution]
|
|
233
|
+
* @param {"pay"|"stop"|((preflight: object) => boolean|Promise<boolean>)} [options.onIncomplete] a "go" whose checks_complete
|
|
234
|
+
* is false (a check that applies couldn't run): "pay" (default), "stop" throws code "incomplete", a function decides
|
|
233
235
|
* @param {string[]} [options.trusted] hosts you already trust: paid without a preflight
|
|
234
236
|
* @param {(preflight: object, info: {url: string, method: string, cached: boolean}) => void} [options.onPreflight]
|
|
235
237
|
* @param {number} [options.cacheMs] how long a verdict is reused while the 402 offers the same thing (default 1 hour; at most 10 minutes when the offer can't be read)
|
|
@@ -250,6 +252,7 @@ export function createSafeFetch({
|
|
|
250
252
|
network = BASE,
|
|
251
253
|
maxUsd = 0.05,
|
|
252
254
|
onCaution = "stop",
|
|
255
|
+
onIncomplete = "pay",
|
|
253
256
|
trusted = [],
|
|
254
257
|
onPreflight,
|
|
255
258
|
cacheMs = 60 * 60 * 1000,
|
|
@@ -273,6 +276,9 @@ export function createSafeFetch({
|
|
|
273
276
|
if (!["stop", "pay"].includes(onCaution) && typeof onCaution !== "function") {
|
|
274
277
|
throw new TypeError('onCaution must be "stop", "pay" or a function');
|
|
275
278
|
}
|
|
279
|
+
if (!["pay", "stop"].includes(onIncomplete) && typeof onIncomplete !== "function") {
|
|
280
|
+
throw new TypeError('onIncomplete must be "pay", "stop" or a function');
|
|
281
|
+
}
|
|
276
282
|
if (!["require", "off"].includes(verifyReceipts)) throw new TypeError('verifyReceipts must be "require" or "off"');
|
|
277
283
|
const trustedHosts = new Set(trusted.map(hostOf).filter(Boolean));
|
|
278
284
|
|
|
@@ -309,7 +315,9 @@ export function createSafeFetch({
|
|
|
309
315
|
async function preflightFor(url, method, offer) {
|
|
310
316
|
const key = `${method} ${url}`;
|
|
311
317
|
const hit = verdicts.get(key);
|
|
312
|
-
|
|
318
|
+
// An unreadable offer, or a verdict on less than usual (checks_complete false), is reused for at most 10 minutes.
|
|
319
|
+
const short = offer === null || hit?.preflight?.checks_complete === false;
|
|
320
|
+
const ttl = short ? Math.min(cacheMs, UNREAD_OFFER_MS) : cacheMs;
|
|
313
321
|
if (hit && hit.offer === offer && hit.at + ttl > now()) return { preflight: hit.preflight, cached: true };
|
|
314
322
|
const pfUrl = preflightUrl(url, { method, maxUsd: budget, network: payNetwork, doctorUrl });
|
|
315
323
|
const res = await payPreflight(pfUrl, { headers: DOCTOR_HEADERS });
|
|
@@ -391,6 +399,8 @@ export function createSafeFetch({
|
|
|
391
399
|
if (target.protocol !== "https:" && !["localhost", "127.0.0.1"].includes(target.hostname)) return;
|
|
392
400
|
if (!sameSite(target.hostname, endpoint.hostname)) return;
|
|
393
401
|
if (refusal.report.resource !== `${endpoint.origin}${endpoint.pathname}`) return;
|
|
402
|
+
// The draft takes a screening_hit only signed, and this client holds no key to sign with.
|
|
403
|
+
if (refusal.signature_required || (refusal.report.reason === "screening_hit" && !refusal.report.signature)) return;
|
|
394
404
|
const key = `${refusal.report.resource} ${refusal.report.reason}`;
|
|
395
405
|
if (now() - (toldSellers.get(key) ?? -Infinity) < 24 * 60 * 60 * 1000) return;
|
|
396
406
|
toldSellers.set(key, now());
|
|
@@ -399,7 +409,8 @@ export function createSafeFetch({
|
|
|
399
409
|
.then(() => baseFetch(target.href, {
|
|
400
410
|
method: "POST",
|
|
401
411
|
headers: { "content-type": "application/json" },
|
|
402
|
-
|
|
412
|
+
// No "by": buyers aren't grouped by the client they use.
|
|
413
|
+
body: JSON.stringify(refusal.report),
|
|
403
414
|
redirect: "error",
|
|
404
415
|
}))
|
|
405
416
|
.then((r) => r?.body?.cancel?.())
|
|
@@ -465,6 +476,12 @@ export function createSafeFetch({
|
|
|
465
476
|
}
|
|
466
477
|
} else if (preflight.verdict !== "go") {
|
|
467
478
|
throw new SafePayError(`not paid: unknown verdict ${preflight.verdict}`, { code: "preflight_failed", preflight, url });
|
|
479
|
+
} else if (preflight.checks_complete === false && onIncomplete !== "pay") {
|
|
480
|
+
// "go" on less than usual: a check that applies couldn't run (Doctor's not_checked says which). Not the seller's
|
|
481
|
+
// doing, so no refusal is reported to it.
|
|
482
|
+
const ok = typeof onIncomplete === "function" && (await onIncomplete(preflight)) === true;
|
|
483
|
+
const missing = (preflight.not_checked || []).filter((n) => n?.because === "unavailable").map((n) => n.check).join(", ") || "unknown";
|
|
484
|
+
if (!ok) throw new SafePayError(`not paid: go, but not every check ran (${missing}); try again in a few minutes`, { code: "incomplete", preflight, url });
|
|
468
485
|
}
|
|
469
486
|
return payAfterPreflight();
|
|
470
487
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "x402-safe-fetch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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": {
|