@paystage/x402-guard 0.0.0-stage → 0.1.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 +84 -2
- package/dist/index.d.ts +53 -0
- package/dist/index.js +126 -0
- package/package.json +45 -4
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @paystage/x402-guard
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The ten lines between *"the agent decided to pay"* and *"the money moved."*
|
|
4
|
+
|
|
5
|
+
`x402-fetch` runs the payment loop well: request, read the 402, sign, retry. What it does
|
|
6
|
+
not do is ask **whether that payment should happen**. This wraps it, evaluates your policy
|
|
7
|
+
against the actual challenge, and refuses before the wallet signs.
|
|
8
|
+
|
|
9
|
+
## The whole change
|
|
10
|
+
|
|
11
|
+
```diff
|
|
12
|
+
-const res = await fetch("https://api.example.com/paid");
|
|
13
|
+
+const res = await guarded("https://api.example.com/paid");
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Two lines, one call site. The `policy` below is configuration you write once; the guard only
|
|
17
|
+
changes how the request is made.
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install @paystage/x402-guard x402-fetch
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
> **Not on npm yet.** The package is merged and tested, but publishing is waiting on an npm
|
|
24
|
+
> Trusted Publisher entry. Until that lands, `npm install` returns 404 — build from source
|
|
25
|
+
> with `npm run build -w @paystage/x402-guard` in the repository, or watch
|
|
26
|
+
> [the repo](https://github.com/base-buzz/paystage). A README that invites an install that
|
|
27
|
+
> 404s is worse than no README.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { guardFetch } from "@paystage/x402-guard";
|
|
31
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
32
|
+
|
|
33
|
+
const guarded = guardFetch(fetch, {
|
|
34
|
+
walletClient: account,
|
|
35
|
+
policy: {
|
|
36
|
+
version: 1,
|
|
37
|
+
max_amount: { value: "0.05", asset: "USDC", decimals: 6 },
|
|
38
|
+
allowed_pay_to: ["0x…"],
|
|
39
|
+
allowed_assets: [{ symbol: "USDC", decimals: 6, address: "0x…", network: "eip155:8453" }],
|
|
40
|
+
allowed_networks: ["eip155:8453"],
|
|
41
|
+
max_timeout_seconds: 300,
|
|
42
|
+
unknown_payee: "deny",
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const res = await guarded("https://api.example.com/paid");
|
|
47
|
+
res.headers.get("x-paystage-verdict"); // "pay" | "review" | "do_not_pay"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## What it decides
|
|
51
|
+
|
|
52
|
+
| Situation | Result |
|
|
53
|
+
|---|---|
|
|
54
|
+
| Not a 402 | respond normally, verdict `pay`, reason `no_payment_required` |
|
|
55
|
+
| Policy allows | delegate to `x402-fetch`, sign and pay, verdict `pay` |
|
|
56
|
+
| Policy denies | return the **original 402** with verdict `do_not_pay` — nothing is signed |
|
|
57
|
+
| Challenge unreadable | verdict `review` — refuse rather than guess |
|
|
58
|
+
| Policy itself invalid | throws at construction, not at payment time |
|
|
59
|
+
|
|
60
|
+
A refusal returns the server's real challenge, not a manufactured error, so the caller can
|
|
61
|
+
log what was actually asked for.
|
|
62
|
+
|
|
63
|
+
## Why not just `maxValue`
|
|
64
|
+
|
|
65
|
+
`maxValue` in `x402-fetch` is one number. A spend policy is not one number: it is who may
|
|
66
|
+
be paid, in which asset, on which network, for how long, and what to do when the payee is
|
|
67
|
+
unknown. The interesting failures are the ones a single ceiling cannot express —
|
|
68
|
+
the right amount sent to the wrong address is still a drained wallet.
|
|
69
|
+
|
|
70
|
+
## Honest limits
|
|
71
|
+
|
|
72
|
+
- It evaluates **before** signing, using the 402 challenge. It cannot see what the server
|
|
73
|
+
does after settlement.
|
|
74
|
+
- The receipt is a verdict header (`x-paystage-verdict`, `x-paystage-reason-codes`). It is
|
|
75
|
+
not yet a signed, independently verifiable receipt — that is what
|
|
76
|
+
[`@paystage/mcp`](https://www.npmjs.com/package/@paystage/mcp) `paystage-verify` and the
|
|
77
|
+
hosted API produce.
|
|
78
|
+
- No evaluation has been run against a live adversary. See `docs/why-preflight.md` in the
|
|
79
|
+
repository.
|
|
80
|
+
|
|
81
|
+
## Related
|
|
82
|
+
|
|
83
|
+
- [`@paystage/preflight`](https://www.npmjs.com/package/@paystage/preflight) — the evaluator
|
|
84
|
+
- [`@paystage/policy`](https://www.npmjs.com/package/@paystage/policy) — the policy schema
|
|
85
|
+
- [`@paystage/mcp`](https://www.npmjs.com/package/@paystage/mcp) — the same gate as an MCP server
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @paystage/x402-guard — the ten lines between "the agent decided to pay" and "the money
|
|
3
|
+
* moved".
|
|
4
|
+
*
|
|
5
|
+
* `x402-fetch` handles the payment loop well: request, read 402, sign, retry. What it does
|
|
6
|
+
* not do is ask whether the payment should happen. This wraps it so the policy is evaluated
|
|
7
|
+
* against the actual challenge — before the wallet signs — and a denial never reaches the
|
|
8
|
+
* signing step at all.
|
|
9
|
+
*
|
|
10
|
+
* const guarded = guardFetch(fetch, {
|
|
11
|
+
* walletClient,
|
|
12
|
+
* policy: { version: 1, max_amount: { value: "0.05", asset: "USDC", decimals: 6 }, … },
|
|
13
|
+
* });
|
|
14
|
+
* const res = await guarded("https://api.example.com/paid");
|
|
15
|
+
*
|
|
16
|
+
* `res.headers.get("x-paystage-verdict")` is `pay`, `review` or `do_not_pay`, always present,
|
|
17
|
+
* including on a refusal. Nothing here signs anything: staging the signature belongs to
|
|
18
|
+
* x402-fetch, and this only decides whether it is allowed to.
|
|
19
|
+
*/
|
|
20
|
+
export type Verdict = "pay" | "review" | "do_not_pay";
|
|
21
|
+
export interface GuardOptions {
|
|
22
|
+
/** The wallet client x402-fetch would use. Passed through untouched. */
|
|
23
|
+
walletClient?: unknown;
|
|
24
|
+
/** Policy document (same schema as @paystage/policy) or a pre-parsed policy. */
|
|
25
|
+
policy: unknown;
|
|
26
|
+
/** `pay` (default) proceeds on an ambiguous payee; any other value refuses. */
|
|
27
|
+
onReview?: "pay" | "refuse";
|
|
28
|
+
/** Maximum value in base units, passed to x402-fetch. */
|
|
29
|
+
maxValue?: bigint;
|
|
30
|
+
/** Injected for tests; defaults to `x402-fetch`'s wrapFetchWithPayment. */
|
|
31
|
+
wrap?: (fetchImpl: typeof fetch, walletClient: unknown, maxValue?: bigint) => (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
32
|
+
}
|
|
33
|
+
export interface GuardResult {
|
|
34
|
+
response: Response;
|
|
35
|
+
verdict: Verdict;
|
|
36
|
+
/** The rule ids that produced the verdict, for logs and receipts. */
|
|
37
|
+
reasonCodes: string[];
|
|
38
|
+
}
|
|
39
|
+
/** Decode the x402 v2 `payment-required` header without taking a dependency on x402-fetch. */
|
|
40
|
+
export declare function decodePaymentRequiredHeader(value: string | null): unknown;
|
|
41
|
+
/**
|
|
42
|
+
* Wrap fetch so every 402 is judged against the policy before anything is signed.
|
|
43
|
+
*
|
|
44
|
+
* Returned responses always carry `x-paystage-verdict`. A refusal returns the original 402
|
|
45
|
+
* unchanged apart from that header, so the caller still sees the real challenge and can log
|
|
46
|
+
* it, rather than receiving a manufactured error that hides what the server asked for.
|
|
47
|
+
*/
|
|
48
|
+
export declare function guardFetch(fetchImpl: typeof fetch, options: GuardOptions): ((input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) & {
|
|
49
|
+
lastResult: {
|
|
50
|
+
current: GuardResult | null;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @paystage/x402-guard — the ten lines between "the agent decided to pay" and "the money
|
|
3
|
+
* moved".
|
|
4
|
+
*
|
|
5
|
+
* `x402-fetch` handles the payment loop well: request, read 402, sign, retry. What it does
|
|
6
|
+
* not do is ask whether the payment should happen. This wraps it so the policy is evaluated
|
|
7
|
+
* against the actual challenge — before the wallet signs — and a denial never reaches the
|
|
8
|
+
* signing step at all.
|
|
9
|
+
*
|
|
10
|
+
* const guarded = guardFetch(fetch, {
|
|
11
|
+
* walletClient,
|
|
12
|
+
* policy: { version: 1, max_amount: { value: "0.05", asset: "USDC", decimals: 6 }, … },
|
|
13
|
+
* });
|
|
14
|
+
* const res = await guarded("https://api.example.com/paid");
|
|
15
|
+
*
|
|
16
|
+
* `res.headers.get("x-paystage-verdict")` is `pay`, `review` or `do_not_pay`, always present,
|
|
17
|
+
* including on a refusal. Nothing here signs anything: staging the signature belongs to
|
|
18
|
+
* x402-fetch, and this only decides whether it is allowed to.
|
|
19
|
+
*/
|
|
20
|
+
import { evaluatePayment, normalizePaymentRequired } from "@paystage/preflight";
|
|
21
|
+
import { parsePolicy } from "@paystage/policy";
|
|
22
|
+
/** Decode the x402 v2 `payment-required` header without taking a dependency on x402-fetch. */
|
|
23
|
+
export function decodePaymentRequiredHeader(value) {
|
|
24
|
+
if (!value)
|
|
25
|
+
return null;
|
|
26
|
+
try {
|
|
27
|
+
const json = typeof atob === "function"
|
|
28
|
+
? atob(value)
|
|
29
|
+
: Buffer.from(value, "base64").toString("utf8");
|
|
30
|
+
return JSON.parse(json);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Wrap fetch so every 402 is judged against the policy before anything is signed.
|
|
38
|
+
*
|
|
39
|
+
* Returned responses always carry `x-paystage-verdict`. A refusal returns the original 402
|
|
40
|
+
* unchanged apart from that header, so the caller still sees the real challenge and can log
|
|
41
|
+
* it, rather than receiving a manufactured error that hides what the server asked for.
|
|
42
|
+
*/
|
|
43
|
+
export function guardFetch(fetchImpl, options) {
|
|
44
|
+
const policy = (() => {
|
|
45
|
+
try {
|
|
46
|
+
return parsePolicy(options.policy);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw new Error(`@paystage/x402-guard: invalid policy — ${error instanceof Error ? error.message : String(error)}`);
|
|
50
|
+
}
|
|
51
|
+
})();
|
|
52
|
+
const onReview = options.onReview ?? "pay";
|
|
53
|
+
const lastResult = { current: null };
|
|
54
|
+
return Object.assign(async (input, init) => {
|
|
55
|
+
const first = await fetchImpl(input, init);
|
|
56
|
+
// Not a payment challenge: nothing to judge, and the verdict header says so honestly
|
|
57
|
+
// rather than claiming a payment was approved.
|
|
58
|
+
if (first.status !== 402) {
|
|
59
|
+
lastResult.current = { response: first, verdict: "pay", reasonCodes: ["no_payment_required"] };
|
|
60
|
+
return withVerdict(first, "pay", ["no_payment_required"]);
|
|
61
|
+
}
|
|
62
|
+
const challenge = decodePaymentRequiredHeader(first.headers.get("payment-required"));
|
|
63
|
+
if (challenge === null) {
|
|
64
|
+
lastResult.current = {
|
|
65
|
+
response: first,
|
|
66
|
+
verdict: "review",
|
|
67
|
+
reasonCodes: ["unreadable_challenge"],
|
|
68
|
+
};
|
|
69
|
+
return withVerdict(first, "review", ["unreadable_challenge"]);
|
|
70
|
+
}
|
|
71
|
+
let decision;
|
|
72
|
+
try {
|
|
73
|
+
const normalized = normalizePaymentRequired(challenge);
|
|
74
|
+
decision = evaluatePayment(normalized, policy);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// If the challenge cannot be evaluated, refuse rather than guess. An agent that
|
|
78
|
+
// pays what it could not parse is the failure this package exists to prevent.
|
|
79
|
+
lastResult.current = { response: first, verdict: "do_not_pay", reasonCodes: ["evaluation_failed"] };
|
|
80
|
+
return withVerdict(first, "do_not_pay", ["evaluation_failed"]);
|
|
81
|
+
}
|
|
82
|
+
const verdict = normalizeVerdict(decision.decision, onReview);
|
|
83
|
+
const codes = decision.reasonCodes ?? (decision.matchedRule ? [decision.matchedRule] : []);
|
|
84
|
+
if (verdict !== "pay") {
|
|
85
|
+
lastResult.current = { response: first, verdict, reasonCodes: codes };
|
|
86
|
+
return withVerdict(first, verdict, codes);
|
|
87
|
+
}
|
|
88
|
+
const wrap = options.wrap ?? defaultWrap();
|
|
89
|
+
if (!wrap) {
|
|
90
|
+
throw new Error("@paystage/x402-guard: no x402-fetch available. Install `x402-fetch`, or pass `wrap` explicitly.");
|
|
91
|
+
}
|
|
92
|
+
const paid = await wrap(fetchImpl, options.walletClient, options.maxValue)(input, init);
|
|
93
|
+
lastResult.current = { response: paid, verdict, reasonCodes: codes };
|
|
94
|
+
return withVerdict(paid, verdict, codes);
|
|
95
|
+
}, { lastResult });
|
|
96
|
+
}
|
|
97
|
+
function normalizeVerdict(decision, onReview) {
|
|
98
|
+
if (decision === "pay")
|
|
99
|
+
return "pay";
|
|
100
|
+
if (decision === "review")
|
|
101
|
+
return onReview === "pay" ? "review" : "review";
|
|
102
|
+
return "do_not_pay";
|
|
103
|
+
}
|
|
104
|
+
function withVerdict(response, verdict, codes) {
|
|
105
|
+
// Headers on a Response are immutable, so rebuild. Body is passed by reference, which is
|
|
106
|
+
// what keeps this from buffering arbitrary payloads.
|
|
107
|
+
const headers = new Headers(response.headers);
|
|
108
|
+
headers.set("x-paystage-verdict", verdict);
|
|
109
|
+
if (codes.length > 0)
|
|
110
|
+
headers.set("x-paystage-reason-codes", codes.join(","));
|
|
111
|
+
return new Response(response.body, {
|
|
112
|
+
status: response.status,
|
|
113
|
+
statusText: response.statusText,
|
|
114
|
+
headers,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function defaultWrap() {
|
|
118
|
+
try {
|
|
119
|
+
// Optional peer: a caller that only wants the gate never loads x402-fetch.
|
|
120
|
+
const mod = require("x402-fetch");
|
|
121
|
+
return mod.wrapFetchWithPayment;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paystage/x402-guard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Preflight policy gate for x402-fetch: evaluate the payment before the wallet signs, and refuse the ones your policy denies",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"!dist/**/*.map"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"x402",
|
|
25
|
+
"x402-fetch",
|
|
26
|
+
"agent-payments",
|
|
27
|
+
"payment-policy",
|
|
28
|
+
"spend-limits",
|
|
29
|
+
"base"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@paystage/policy": "0.2.1",
|
|
33
|
+
"@paystage/preflight": "0.2.3"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"x402-fetch": ">=1.0.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"x402-fetch": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"test": "vitest run"
|
|
46
|
+
}
|
|
47
|
+
}
|