@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 CHANGED
@@ -1,3 +1,85 @@
1
- # Temporary Holding Version
1
+ # @paystage/x402-guard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }