x402-safe-fetch 0.16.1 → 0.16.2

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
@@ -144,7 +144,9 @@ The preflight itself is capped at $0.002 and paid with the same schemes. `receip
144
144
 
145
145
  A seller can take the payment and still answer with an error: a 405, a 500, or another 402 asking to pay again. Paying the same request again then usually costs a second payment for nothing. In the agent exam at [exam.fizzl.eu](https://exam.fizzl.eu) this was the most common loss: every model we examined did it at least once, with or without a preflight, because a preflight only sees the 402 before paying.
146
146
 
147
- So safe-fetch remembers each paid request (method, URL and body) whose paid answer was not a success. Calling that same request again within 30 minutes throws `SafePayError` with code `paid_no_delivery` (and `error.earlier`: when it was paid and what it was answered with), without paying and without buying another preflight. A different request is not affected, and a success clears it.
147
+ So safe-fetch remembers each paid endpoint (method, origin and path) whose paid answer was not a success. Calling that endpoint again within 30 minutes, with any query string or body, throws `SafePayError` with code `paid_no_delivery` (and `error.earlier`: when it was paid and what it was answered with), without paying and without buying another preflight. Another endpoint is not affected, and a success clears it.
148
+
149
+ The query and the body are left out on purpose. Since 0.16.2: in the exam, an open model that was refused added `&nonce=1`, `&nonce=2`, … to the URL, and each "new" request was paid (eight times). A seller that took your money without delivering doesn't get paid again on that endpoint, whatever the agent changes in the request.
148
150
 
149
151
  ```js
150
152
  const safeFetch = createSafeFetch({
package/index.d.ts CHANGED
@@ -152,7 +152,7 @@ export declare function verifyReceipt(body: unknown, options?: { signers?: strin
152
152
  /** The reason code reported with a refused outcome: the preflight's first reason at its verdict's level. */
153
153
  export function refusalReason(preflight: { verdict?: string; reasons?: { level?: string; code?: string }[] }): string;
154
154
 
155
- /** Remembers paid requests that weren't delivered, so the same request isn't paid again (used by createSafeFetch; usable on its own). */
155
+ /** Remembers paid endpoints (method, origin and path) that took a payment without delivering, so they aren't paid again with any query or body (used by createSafeFetch; usable on its own). */
156
156
  export declare function createPaidGuard(options?: { windowMs?: number; now?: () => number }): {
157
157
  blocked(url: string, init?: { method?: string; body?: unknown }): { at: number; status: number } | null;
158
158
  record(url: string, init: { method?: string; body?: unknown } | undefined, status: number): void;
package/index.js CHANGED
@@ -25,8 +25,8 @@
25
25
  // hash of the refused offer; no query, no keys, nothing about the buyer or this client), once a day per endpoint and
26
26
  // reason. A screening_hit isn't sent: the draft takes it only signed. Off by default.
27
27
  // A paid request answered without delivery (an error, or another 402) is never paid again on its own: the same
28
- // method, URL and body within paidErrorMs throws SafePayError code "paid_no_delivery" (onPaidError "stop", the
29
- // default). The most common loss in the agent exam (exam.fizzl.eu) was paying again after a seller took the payment
28
+ // endpoint (method, origin and path; any query or body) within paidErrorMs throws SafePayError code
29
+ // "paid_no_delivery" (onPaidError "stop", the default). The most common loss in the agent exam (exam.fizzl.eu) was paying again after a seller took the payment
30
30
  // and answered with an error; a preflight can't see that, it only reads the 402 before paying (paid-guard.js).
31
31
  // Every preflight must carry Doctor's signed receipt for exactly this request
32
32
  // (endpoint, method, budget, network), checked against Doctor's pinned signer:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-safe-fetch",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
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": {
package/paid-guard.js CHANGED
@@ -4,32 +4,29 @@
4
4
  // (a 405, a 500, or another 402 asking to pay again), and the agent pays the same request again, and again. Every
5
5
  // model we examined did it at least once, with or without a preflight: the preflight only sees the 402 before paying.
6
6
  //
7
- // The guard remembers each paid request (method, URL and body) whose paid answer was not a success. Paying that same
8
- // request again within the window is refused, so the loss stops at one payment. A success clears it. A different
9
- // request (another URL, method or body) is not affected. No dependencies: it runs anywhere fetch does.
7
+ // The guard remembers each paid endpoint (method, origin and path) whose paid answer was not a success. Paying that
8
+ // endpoint again within the window is refused, so the loss stops at one payment. The query string and the body are
9
+ // left out on purpose: in the exam, a model that was refused added "&nonce=1", "&nonce=2", ... to the URL and paid
10
+ // eight more times. A success clears it. Another endpoint (another path, method or host) is not affected. No
11
+ // dependencies: it runs anywhere fetch does.
10
12
 
11
13
  export const PAID_ERROR_MS = 30 * 60 * 1000;
12
14
 
13
- // A short, stable fingerprint of the body (FNV-1a, 32 bit): the key holds no request content.
14
- function bodyKey(body) {
15
- if (body === undefined || body === null) return "";
16
- const text = typeof body === "string" ? body : body instanceof URLSearchParams ? body.toString() : ArrayBuffer.isView(body) ? new TextDecoder().decode(body) : body instanceof ArrayBuffer ? new TextDecoder().decode(new Uint8Array(body)) : String(body);
17
- let h = 0x811c9dc5;
18
- for (let i = 0; i < text.length; i++) {
19
- h ^= text.charCodeAt(i);
20
- h = Math.imul(h, 0x01000193) >>> 0;
15
+ /** The endpoint as the guard sees it: method, origin and path (no query, no body, no fragment). */
16
+ export function purchaseKey(url, { method = "GET" } = {}) {
17
+ let endpoint = String(url);
18
+ try {
19
+ const u = new URL(endpoint);
20
+ endpoint = `${u.origin}${u.pathname.replace(/\/+$/, "") || "/"}`;
21
+ } catch {
22
+ endpoint = endpoint.split(/[?#]/)[0];
21
23
  }
22
- return `${text.length}:${h.toString(16)}`;
23
- }
24
-
25
- /** The request as the guard sees it: method, URL and a fingerprint of the body. */
26
- export function purchaseKey(url, { method = "GET", body } = {}) {
27
- return `${String(method).toUpperCase()} ${String(url)} ${bodyKey(body)}`;
24
+ return `${String(method).toUpperCase()} ${endpoint}`;
28
25
  }
29
26
 
30
27
  /**
31
28
  * @param {object} [options]
32
- * @param {number} [options.windowMs] how long a paid request that wasn't delivered stays blocked (default 30 minutes)
29
+ * @param {number} [options.windowMs] how long a paid endpoint that didn't deliver stays blocked (default 30 minutes)
33
30
  * @param {() => number} [options.now]
34
31
  */
35
32
  export function createPaidGuard({ windowMs = PAID_ERROR_MS, now = Date.now } = {}) {
@@ -60,5 +57,5 @@ export function createPaidGuard({ windowMs = PAID_ERROR_MS, now = Date.now } = {
60
57
 
61
58
  /** The refusal text, for an error or a tool answer. */
62
59
  export function paidTwiceMessage(url, earlier) {
63
- return `not paid again: ${url} was already paid at ${new Date(earlier.at).toISOString()} and answered HTTP ${earlier.status} without delivering. Paying the same request again would most likely be charged again for nothing; ask the seller, or try a different request.`;
60
+ return `not paid again: ${purchaseKey(url).replace(/^GET /, "")} was already paid at ${new Date(earlier.at).toISOString()} and answered HTTP ${earlier.status} without delivering. Paying this endpoint again (with any query or body) would most likely be charged again for nothing; ask the seller, or use another seller.`;
64
61
  }