x402-safe-fetch 0.11.1 → 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 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 |
@@ -146,7 +147,7 @@ With `tellSellers: true`, a refusal also goes straight to the seller, when its 4
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, 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 (0.2) 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
+ 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
@@ -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.11.1";
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
- const ttl = offer === null ? Math.min(cacheMs, UNREAD_OFFER_MS) : cacheMs;
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,7 +399,7 @@ 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;
394
- // The draft (0.2) takes a screening_hit only signed, and this client holds no key to sign with.
402
+ // The draft takes a screening_hit only signed, and this client holds no key to sign with.
395
403
  if (refusal.signature_required || (refusal.report.reason === "screening_hit" && !refusal.report.signature)) return;
396
404
  const key = `${refusal.report.resource} ${refusal.report.reason}`;
397
405
  if (now() - (toldSellers.get(key) ?? -Infinity) < 24 * 60 * 60 * 1000) return;
@@ -468,6 +476,12 @@ export function createSafeFetch({
468
476
  }
469
477
  } else if (preflight.verdict !== "go") {
470
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 });
471
485
  }
472
486
  return payAfterPreflight();
473
487
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-safe-fetch",
3
- "version": "0.11.1",
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": {