@keelcodes/x402-gate 0.0.0-stage → 0.1.1

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,147 @@
1
- # Temporary Holding Version
1
+ # @keelcodes/x402-gate
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
+ 给任意 HTTP 接口加一道 x402 v2 收费门:**不持私钥、不接触链**,只把客户端的
4
+ `PAYMENT-SIGNATURE` 转给支付引擎验签/结算。
5
+
6
+ - 标准轨:`exact` over **EIP-3009**(Base USDC)或 **Permit2**(BSC USDT/USDC、任意 ERC-20、智能账户)
7
+ - Keel 轨:客户端先转账、载荷里带 `txHash`,网关自动改用 `/x402/verify`
8
+ - 运行时无关:Express / Fetch / Next.js App Router(含 edge)/ Workers
9
+
10
+ ## 安装
11
+
12
+ ```bash
13
+ npm i @keelcodes/x402-gate
14
+ ```
15
+
16
+ ## Express
17
+
18
+ ```ts
19
+ import express from 'express'
20
+ import { createX402Gate } from '@keelcodes/x402-gate'
21
+
22
+ const app = express()
23
+ app.use(express.json())
24
+
25
+ const gate = createX402Gate({
26
+ paymentsUrl: 'https://keel.codes/v1/payment',
27
+ paymentsKey: process.env.PAYMENTS_KEY,
28
+ payTo: '0xYourReceivingAddress',
29
+ amount: '2000', // 最小单位
30
+ accepts: [
31
+ { network: 'eip155:8453', asset: '0x8335…2913', eip3009: { name: 'USD Coin', version: '2' } },
32
+ { network: 'eip155:56', asset: '0x55d3…7955', permit2: true },
33
+ ],
34
+ })
35
+
36
+ app.get('/quote', gate, (_req, res) => res.json({ price: 42 }))
37
+ ```
38
+
39
+ ## Next.js App Router
40
+
41
+ ```ts
42
+ import { withX402 } from '@keelcodes/x402-gate'
43
+
44
+ const gateOptions = { /* 同上 */ }
45
+
46
+ export const GET = withX402(gateOptions, async () => Response.json({ price: 42 }))
47
+ ```
48
+
49
+ ## 配置
50
+
51
+ | 字段 | 说明 |
52
+ |---|---|
53
+ | `paymentsUrl` | 支付引擎地址(自建或 `https://keel.codes/v1/payment`) |
54
+ | `paymentsKey` | 支付引擎 Bearer key |
55
+ | `payTo` | 收款地址(B 端自己的钱包;资金直达,不经平台) |
56
+ | `amount` | 单次价格(最小单位);也可用 `amountFor(path)` 动态定价 |
57
+ | `accepts` | 多链多资产;每项二选一:`eip3009: { name, version }` 或 `permit2: true` |
58
+ | `exempt` | 免费路径前缀(默认已含 `/health`、`/metrics`) |
59
+ | `passthroughHeaders` | 额外视为「已有平台凭证」的头(如 `x-tenant-id`) |
60
+ | `resource` | 写进 402 `resource` 的服务元数据:`description` / `mimeType` / `serviceName` / `tags` / `iconUrl`(目录与 agent 读取) |
61
+ | `bazaar` | Bazaar 扩展 `{ info, schema? }`;facilitator 据此索引本资源 |
62
+ | `freeQuota` | 免费额度:`{ calls, period: 'day'\|'month', windowMs?, key?, now? }`;窗口内前 N 次直接放行(响应带 `X-Free-Quota-Remaining`),用完后转 402 |
63
+
64
+ 已有平台凭证(`Authorization` / `X-API-Key` / `X-Service-Key`)的请求直接放行,
65
+ 不影响原来的「人」的调用。
66
+
67
+ ## 被发现 + 免费额度
68
+
69
+ ```ts
70
+ const gate = createX402Gate({
71
+ paymentsUrl: 'https://keel.codes/v1/payment',
72
+ payTo: '0xYourReceivingAddress',
73
+ network: 'eip155:8453',
74
+ asset: '0x8335…2913',
75
+ amount: '2000',
76
+ domain: { name: 'USD Coin', version: '2' },
77
+ // 目录/agent 从 402 里读到这些信息
78
+ resource: {
79
+ serviceName: 'My Tick API',
80
+ description: 'Per-request price feed',
81
+ mimeType: 'application/json',
82
+ tags: ['market-data'],
83
+ },
84
+ bazaar: { info: { input: { type: 'http', method: 'GET' }, output: { type: 'json' } } },
85
+ // 每 IP 每天前 100 次免费(沙箱体验),之后 402
86
+ freeQuota: { calls: 100, period: 'day' },
87
+ })
88
+ ```
89
+
90
+ - 免费额度按 `serviceName|payTo` + 调用方(缺省 `req.ip`,用 `key` 可换成 X-Forwarded-For / 租户)分桶,窗口滚动。
91
+ - `calls: 0` 表示关闭免费额度。
92
+
93
+ ## 客户端怎么付
94
+
95
+ 任何标准 x402 客户端都行:先发请求拿 `402 + PAYMENT-REQUIRED`,选一个 `accepts`
96
+ 签名,再带 `PAYMENT-SIGNATURE` 重试。用 Keel SDK 时这一套是自动的:
97
+
98
+ > v1 客户端(`X-PAYMENT`)也兼容:网关同时读 `X-PAYMENT`,402 响应体降级为 v1 结构
99
+ > (`x402Version: 1` + `maxAmountRequired` + 短网络名 `base`),成功后额外回
100
+ > `X-PAYMENT-RESPONSE`。`PAYMENT-REQUIRED` 头始终是 v2。
101
+
102
+ ```ts
103
+ const keel = new KeelClient({
104
+ x402: { address, signTypedData, signPermit2TypedData },
105
+ })
106
+ await keel.get('https://api.example/quote') // 402 → 签名 → 重试 → 200
107
+ ```
108
+
109
+ ## 状态码
110
+
111
+ | 场景 | 响应 |
112
+ |---|---|
113
+ | 未付款 | `402` + `PAYMENT-REQUIRED`(含 `accepts[]` 与 `error`) |
114
+ | 结算成功 | 原接口响应 + `PAYMENT-RESPONSE`(`transaction` / `payer` / `amount`) |
115
+ | 结算失败 | `402` + `PAYMENT-REQUIRED`(`error` 带引擎给的原因) |
116
+ | 引擎不可达 | `402`(不返回 500,客户端可重试) |
117
+ | 需要先授权 Permit2 | 引擎在 `PAYMENT-RESPONSE` 前返回 `412 PERMIT2_ALLOWANCE_REQUIRED` |
118
+
119
+ ## 上架登记(市场)
120
+
121
+ B 端要让外部用户/agent 发现你的服务,需要登记 `payTo` 并证明所有权:
122
+
123
+ ```bash
124
+ # 1. 取待签名内容
125
+ curl -sX POST https://keel.codes/v1/payment/market/listings/challenge \
126
+ -H 'Content-Type: application/json' \
127
+ -d '{"payTo":"0xB端收款地址","network":"eip155:56","asset":"0x55d3…7955","amount":"2000","method":"permit2"}'
128
+
129
+ # 2. 用 payTo 的私钥对返回的 typedData 做 EIP-712 签名,提交
130
+ curl -sX POST https://keel.codes/v1/payment/market/listings \
131
+ -H 'Content-Type: application/json' \
132
+ -d '{"payTo":"0x…","network":"eip155:56","asset":"0x55d3…","amount":"2000","method":"permit2","nonce":"0x…","signature":"0x…"}'
133
+ ```
134
+
135
+ - 签名者必须等于 `payTo`,否则 `403 OWNERSHIP_PROOF_FAILED`(冒名无法登记)
136
+ - 默认自动通过;`review: 'manual'` 时进入 `pending`,由管理员 `POST /listings/:id/review` 放行
137
+ - 审核通过后用 `store.approvedPayTos()` 接进支付引擎:
138
+
139
+ ```ts
140
+ facilitator: { allowedPayTo: () => marketplaceStore.approvedPayTos() }
141
+ ```
142
+
143
+ ## 安全边界
144
+
145
+ - 网关只做转发,不持私钥、不读链;被攻破也无法转走资金
146
+ - 收款地址由 B 端自己控制(或第三方白名单),平台不代收
147
+ - 结算凭证(txHash)由支付引擎返回,网关原样透传
@@ -0,0 +1,201 @@
1
+ /**
2
+ * x402 v2 资源网关(标准 exact:EIP-3009 / Permit2)
3
+ *
4
+ * 让任意对外接口在没有平台 API Key 时也接受 agent 的 x402 付款。
5
+ * 鉴权优先级(命中即停):
6
+ * 1. 已有平台凭证(Authorization / X-API-Key / X-Service-Key)→ 放行
7
+ * 2. 无付款凭证 → 402;响应体降级为 v1 结构供老客户端读取,`PAYMENT-REQUIRED` 头为 v2
8
+ * 3. 有 `PAYMENT-SIGNATURE`(或 v1 的 `X-PAYMENT`)→ 调支付引擎 `/x402/settle`
9
+ * (Keel 轨走 `/x402/verify`)→ 200 + `PAYMENT-RESPONSE`(同时回 v1 的 `X-PAYMENT-RESPONSE`)
10
+ *
11
+ * 本包不持私钥、不接触链,只依赖全局 fetch 与 Web 标准 API,因此可以跑在
12
+ * Node / Next.js App Router(含 edge runtime)/ Cloudflare Workers。
13
+ *
14
+ * 多 accept:同时给出 EIP-3009 与 Permit2,客户端按自身能力选择;服务端把完整
15
+ * 挑战(`accepts[]`)转发给支付引擎,由引擎按载荷匹配。
16
+ */
17
+ /** canonical Permit2(各链同址) */
18
+ export declare const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
19
+ /** canonical x402 exact over Permit2 代理(spender,资金只流向 witness.to) */
20
+ export declare const X402_EXACT_PERMIT2_PROXY = "0x402085c248EeA27D92E8b30b2C58ed07f9E20001";
21
+ export interface X402Accept {
22
+ scheme: 'exact';
23
+ network: string;
24
+ amount: string;
25
+ asset: string;
26
+ payTo: string;
27
+ maxTimeoutSeconds: number;
28
+ extra?: Record<string, unknown>;
29
+ }
30
+ export interface X402PaymentRequired {
31
+ x402Version: 2;
32
+ error?: string;
33
+ resource: {
34
+ url: string;
35
+ description?: string;
36
+ mimeType?: string;
37
+ serviceName?: string;
38
+ tags?: string[];
39
+ iconUrl?: string;
40
+ };
41
+ accepts: X402Accept[];
42
+ /** Bazaar 扩展(x402 v2 §extensions/bazaar):让 facilitator 能索引本资源 */
43
+ extensions?: {
44
+ bazaar: {
45
+ info: Record<string, unknown>;
46
+ schema?: Record<string, unknown>;
47
+ };
48
+ };
49
+ /**
50
+ * 非标准扩展:给「按次太贵,买包更便宜」的引导链接。
51
+ * 标准客户端会忽略未知字段,不影响验签与结算。
52
+ */
53
+ links?: X402Link[];
54
+ }
55
+ export interface X402Link {
56
+ /** 目前只用 `purchase`:引导到额度包购买页 */
57
+ rel: 'purchase';
58
+ href: string;
59
+ title?: string;
60
+ }
61
+ /** 资源元数据(写进 402 的 resource,供目录/agent 读取) */
62
+ export interface X402ResourceMeta {
63
+ description?: string;
64
+ mimeType?: string;
65
+ serviceName?: string;
66
+ tags?: string[];
67
+ iconUrl?: string;
68
+ }
69
+ /** Bazaar 扩展内容:input/output 由服务方声明,缺 schema 时 facilitator 按 info 索引 */
70
+ export interface X402BazaarExtension {
71
+ info: Record<string, unknown>;
72
+ schema?: Record<string, unknown>;
73
+ }
74
+ /** 免费额度(B13.3):每个调用方在一个窗口内前 N 次直接放行,之后转 402 */
75
+ export interface X402FreeQuota {
76
+ calls: number;
77
+ /** 窗口长度;缺省按 period 推(day=24h / month=30d) */
78
+ windowMs?: number;
79
+ period?: 'day' | 'month';
80
+ /** 调用方标识(缺省用 req.ip;同一 key 共享额度) */
81
+ key?: (req: GateRequest) => string;
82
+ now?: () => number;
83
+ }
84
+ /** 单个可收款资产:EIP-3009 或 Permit2,二选一。 */
85
+ export interface X402GateAccept {
86
+ /** CAIP-2,如 eip155:8453 */
87
+ network: string;
88
+ /** ERC-20 合约 */
89
+ asset: string;
90
+ /** 覆盖单次价格(缺省用 amount / amountFor) */
91
+ amount?: string;
92
+ /** 覆盖收款地址(缺省用顶层 payTo) */
93
+ payTo?: string;
94
+ maxTimeoutSeconds?: number;
95
+ /** EIP-3009 域;有 `domainName` 的代币(如 Base USDC)走这条 */
96
+ eip3009?: {
97
+ name: string;
98
+ version: string;
99
+ };
100
+ /** 启用 Permit2(任意 ERC-20,含 BSC USDT/USDC 与智能账户) */
101
+ permit2?: boolean | {
102
+ address?: string;
103
+ proxy?: string;
104
+ };
105
+ }
106
+ export interface X402GateOptions {
107
+ /** 支付引擎基础地址,如 https://keel.codes/v1/payment */
108
+ paymentsUrl: string;
109
+ /** 支付引擎鉴权(Bearer) */
110
+ paymentsKey?: string;
111
+ /** 单 accept 简写 */
112
+ payTo?: string;
113
+ network?: string;
114
+ asset?: string;
115
+ amount?: string;
116
+ domain?: {
117
+ name: string;
118
+ version: string;
119
+ };
120
+ /** 多 accept(优先于上面的简写) */
121
+ accepts?: X402GateAccept[];
122
+ /** 命中这些路径前缀的请求不收费(探针/文档) */
123
+ exempt?: string[];
124
+ /** 按请求覆盖价格(最小单位);返回 null 表示不收费 */
125
+ amountFor?: (path: string) => string | null;
126
+ /** 额外视为「已有平台凭证」的请求头(如 x-tenant-id) */
127
+ passthroughHeaders?: string[];
128
+ /** 资源元数据(写进 402 的 resource;B13.1 目录展示用) */
129
+ resource?: X402ResourceMeta;
130
+ /** Bazaar 扩展(写进 402;B13.2 让 facilitator 自动索引) */
131
+ bazaar?: X402BazaarExtension;
132
+ /** 免费额度(B13.3):窗口内前 N 次不收费 */
133
+ freeQuota?: X402FreeQuota;
134
+ /**
135
+ * 购买引导链接(写进 402 的 `links`,A7.1):按次单价通常高于包内均价,
136
+ * 用这个字段告诉 agent/用户「买额度包更便宜」。返回 null 表示该路径不给链接。
137
+ */
138
+ purchaseUrl?: string | ((path: string) => string | null);
139
+ /** 结算超时(毫秒,默认 15s) */
140
+ timeoutMs?: number;
141
+ log?: (msg: string) => void;
142
+ }
143
+ /** 框架无关的请求视图 */
144
+ export interface GateRequest {
145
+ path: string;
146
+ url: string;
147
+ header: (name: string) => string | null;
148
+ /** 调用方地址(免费额度分桶用;缺省视为同一桶) */
149
+ ip?: string;
150
+ }
151
+ export type GateResult = {
152
+ action: 'pass';
153
+ headers?: Record<string, string>;
154
+ } | {
155
+ action: 'challenge';
156
+ status: number;
157
+ headers: Record<string, string>;
158
+ body: unknown;
159
+ };
160
+ export declare function encodeBase64Json(obj: unknown): string;
161
+ export declare function decodeBase64Json(input: string): unknown;
162
+ /**
163
+ * 把「单 accept 简写」与「多 accept」统一展开。
164
+ * 有 `eip3009` 用 EIP-3009 extra;有 `permit2` 用 Permit2 extra;都没给则按 EIP-3009
165
+ * 无域输出(兼容老服务:只靠 `amount` 覆盖价格的场景)。
166
+ */
167
+ export declare function buildAccepts(opts: X402GateOptions, amount: string): X402Accept[];
168
+ /** 402 挑战(标准 v2)——`resource` 带上服务元数据,`extensions.bazaar` 供 facilitator 索引。 */
169
+ export declare function buildChallenge(opts: X402GateOptions, url: string, amount: string, path?: string): X402PaymentRequired;
170
+ /** CAIP-2 → v1 短网络名;未知网络原样返回(v1 客户端自然不支持)。 */
171
+ export declare function toV1Network(network: string): string;
172
+ /**
173
+ * v2 挑战 → v1 `PaymentRequirementsResponse`(v1 把 resource/description/mimeType
174
+ * 放在每个 accept 内,金额字段叫 `maxAmountRequired`)。
175
+ */
176
+ export declare function toV1Challenge(challenge: X402PaymentRequired): Record<string, unknown>;
177
+ /** 仅用于测试/运维:清空内存额度。 */
178
+ export declare function resetFreeQuota(): void;
179
+ /**
180
+ * Keel 轨(legacy verifyOnly):客户端先广播转账,载荷里带 `txHash`。
181
+ * 这类请求走 `/x402/verify`(验链上凭证),而不是 `/x402/settle`(代广播)。
182
+ */
183
+ export declare function legacyTxHash(header: string): string | null;
184
+ /**
185
+ * 网关核心:返回「放行」或「402 挑战」,与框架无关。
186
+ * 结算失败一律 402(携带原因),避免把上游错误变成 500。
187
+ */
188
+ export declare function authorizeX402(opts: X402GateOptions, req: GateRequest): Promise<GateResult>;
189
+ /** Express / Connect 适配器 */
190
+ export declare function createX402Gate(opts: X402GateOptions): (req: any, res: any, next: any) => Promise<void>;
191
+ /** Fetch 处理器(Next.js App Router / Workers 同形) */
192
+ export type FetchHandler = (request: Request) => Promise<Response> | Response;
193
+ /**
194
+ * 包装一个 Fetch 处理器:未付款 → 402;已付款/有平台凭证 → 原处理器。
195
+ *
196
+ * ```ts
197
+ * // app/api/quote/route.ts
198
+ * export const GET = withX402(opts, async () => Response.json({ price: 42 }))
199
+ * ```
200
+ */
201
+ export declare function withX402(opts: X402GateOptions, handler: FetchHandler): FetchHandler;
package/dist/index.js ADDED
@@ -0,0 +1,379 @@
1
+ "use strict";
2
+ /**
3
+ * x402 v2 资源网关(标准 exact:EIP-3009 / Permit2)
4
+ *
5
+ * 让任意对外接口在没有平台 API Key 时也接受 agent 的 x402 付款。
6
+ * 鉴权优先级(命中即停):
7
+ * 1. 已有平台凭证(Authorization / X-API-Key / X-Service-Key)→ 放行
8
+ * 2. 无付款凭证 → 402;响应体降级为 v1 结构供老客户端读取,`PAYMENT-REQUIRED` 头为 v2
9
+ * 3. 有 `PAYMENT-SIGNATURE`(或 v1 的 `X-PAYMENT`)→ 调支付引擎 `/x402/settle`
10
+ * (Keel 轨走 `/x402/verify`)→ 200 + `PAYMENT-RESPONSE`(同时回 v1 的 `X-PAYMENT-RESPONSE`)
11
+ *
12
+ * 本包不持私钥、不接触链,只依赖全局 fetch 与 Web 标准 API,因此可以跑在
13
+ * Node / Next.js App Router(含 edge runtime)/ Cloudflare Workers。
14
+ *
15
+ * 多 accept:同时给出 EIP-3009 与 Permit2,客户端按自身能力选择;服务端把完整
16
+ * 挑战(`accepts[]`)转发给支付引擎,由引擎按载荷匹配。
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.X402_EXACT_PERMIT2_PROXY = exports.PERMIT2_ADDRESS = void 0;
20
+ exports.encodeBase64Json = encodeBase64Json;
21
+ exports.decodeBase64Json = decodeBase64Json;
22
+ exports.buildAccepts = buildAccepts;
23
+ exports.buildChallenge = buildChallenge;
24
+ exports.toV1Network = toV1Network;
25
+ exports.toV1Challenge = toV1Challenge;
26
+ exports.resetFreeQuota = resetFreeQuota;
27
+ exports.legacyTxHash = legacyTxHash;
28
+ exports.authorizeX402 = authorizeX402;
29
+ exports.createX402Gate = createX402Gate;
30
+ exports.withX402 = withX402;
31
+ /** canonical Permit2(各链同址) */
32
+ exports.PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
33
+ /** canonical x402 exact over Permit2 代理(spender,资金只流向 witness.to) */
34
+ exports.X402_EXACT_PERMIT2_PROXY = '0x402085c248EeA27D92E8b30b2C58ed07f9E20001';
35
+ const KEY_HEADERS = ['authorization', 'x-api-key', 'x-service-key'];
36
+ const DEFAULT_EXEMPT = ['/health', '/metrics'];
37
+ // ── base64(Node 与 edge runtime 通用,不依赖 Buffer)───────────────────────
38
+ function encodeBase64Json(obj) {
39
+ const json = JSON.stringify(obj);
40
+ const bytes = new TextEncoder().encode(json);
41
+ let binary = '';
42
+ for (const b of bytes)
43
+ binary += String.fromCharCode(b);
44
+ return btoa(binary);
45
+ }
46
+ function decodeBase64Json(input) {
47
+ const binary = atob(input);
48
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
49
+ return JSON.parse(new TextDecoder().decode(bytes));
50
+ }
51
+ /**
52
+ * 把「单 accept 简写」与「多 accept」统一展开。
53
+ * 有 `eip3009` 用 EIP-3009 extra;有 `permit2` 用 Permit2 extra;都没给则按 EIP-3009
54
+ * 无域输出(兼容老服务:只靠 `amount` 覆盖价格的场景)。
55
+ */
56
+ function buildAccepts(opts, amount) {
57
+ if (opts.accepts?.length) {
58
+ return opts.accepts.map((a) => {
59
+ const target = a.payTo ?? opts.payTo;
60
+ if (!target)
61
+ throw new Error('x402 gate: payTo is required (top-level or per accept)');
62
+ const priced = a.amount ?? amount;
63
+ if (!priced)
64
+ throw new Error('x402 gate: every accept needs an `amount` (or a top-level amount)');
65
+ const base = {
66
+ scheme: 'exact',
67
+ network: a.network,
68
+ amount: priced,
69
+ asset: a.asset,
70
+ payTo: target,
71
+ maxTimeoutSeconds: a.maxTimeoutSeconds ?? 300,
72
+ };
73
+ if (a.permit2) {
74
+ const cfg = typeof a.permit2 === 'object' ? a.permit2 : {};
75
+ return {
76
+ ...base,
77
+ extra: {
78
+ assetTransferMethod: 'permit2',
79
+ permit2Address: cfg.address ?? exports.PERMIT2_ADDRESS,
80
+ permit2Proxy: cfg.proxy ?? exports.X402_EXACT_PERMIT2_PROXY,
81
+ },
82
+ };
83
+ }
84
+ const domain = a.eip3009 ?? opts.domain;
85
+ return domain ? { ...base, extra: { name: domain.name, version: domain.version } } : base;
86
+ });
87
+ }
88
+ if (!opts.payTo || !opts.network || !opts.asset) {
89
+ throw new Error('x402 gate: payTo / network / asset are required when `accepts` is not set');
90
+ }
91
+ return [
92
+ {
93
+ scheme: 'exact',
94
+ network: opts.network,
95
+ amount,
96
+ asset: opts.asset,
97
+ payTo: opts.payTo,
98
+ maxTimeoutSeconds: 300,
99
+ ...(opts.domain ? { extra: { name: opts.domain.name, version: opts.domain.version } } : {}),
100
+ },
101
+ ];
102
+ }
103
+ /** 402 挑战(标准 v2)——`resource` 带上服务元数据,`extensions.bazaar` 供 facilitator 索引。 */
104
+ function buildChallenge(opts, url, amount, path) {
105
+ const challenge = {
106
+ x402Version: 2,
107
+ resource: { url, ...(opts.resource ?? {}) },
108
+ accepts: buildAccepts(opts, amount),
109
+ };
110
+ if (opts.bazaar)
111
+ challenge.extensions = { bazaar: opts.bazaar };
112
+ const purchase = typeof opts.purchaseUrl === 'function'
113
+ ? (path ? opts.purchaseUrl(path) : null)
114
+ : opts.purchaseUrl;
115
+ if (purchase) {
116
+ challenge.links = [{ rel: 'purchase', href: purchase, title: 'Buy a credit pack (cheaper per call)' }];
117
+ }
118
+ return challenge;
119
+ }
120
+ // ── x402 v1 兼容(B15.3)────────────────────────────────────────────────────
121
+ // v1 客户端不会读 `PAYMENT-REQUIRED` 头,只看 402 响应体;并按短网络名匹配
122
+ // (`base` 而非 `eip155:8453`)。因此响应体降级成 v1 结构,请求侧再接受 `X-PAYMENT`。
123
+ const V1_NETWORK_NAMES = {
124
+ 'eip155:1': 'ethereum',
125
+ 'eip155:11155111': 'sepolia',
126
+ 'eip155:8453': 'base',
127
+ 'eip155:84532': 'base-sepolia',
128
+ 'eip155:56': 'bsc',
129
+ 'eip155:97': 'bsc-testnet',
130
+ 'eip155:137': 'polygon',
131
+ 'eip155:43114': 'avalanche',
132
+ 'eip155:10': 'optimism',
133
+ 'eip155:42161': 'arbitrum',
134
+ };
135
+ /** CAIP-2 → v1 短网络名;未知网络原样返回(v1 客户端自然不支持)。 */
136
+ function toV1Network(network) {
137
+ return V1_NETWORK_NAMES[network] ?? network;
138
+ }
139
+ /**
140
+ * v2 挑战 → v1 `PaymentRequirementsResponse`(v1 把 resource/description/mimeType
141
+ * 放在每个 accept 内,金额字段叫 `maxAmountRequired`)。
142
+ */
143
+ function toV1Challenge(challenge) {
144
+ const resource = challenge.resource;
145
+ return {
146
+ x402Version: 1,
147
+ ...(challenge.error ? { error: challenge.error } : {}),
148
+ accepts: challenge.accepts.map((a) => ({
149
+ scheme: a.scheme,
150
+ network: toV1Network(a.network),
151
+ maxAmountRequired: a.amount,
152
+ resource: resource.url,
153
+ ...(resource.description ? { description: resource.description } : {}),
154
+ mimeType: resource.mimeType ?? 'application/json',
155
+ payTo: a.payTo,
156
+ maxTimeoutSeconds: a.maxTimeoutSeconds,
157
+ asset: a.asset,
158
+ ...(a.extra ? { extra: a.extra } : {}),
159
+ })),
160
+ };
161
+ }
162
+ /** 读付款凭证:v2 的 `PAYMENT-SIGNATURE` 优先,回退 v1 的 `X-PAYMENT`。 */
163
+ function paymentSignature(req) {
164
+ return (req.header('payment-signature') ?? req.header('x-payment'))?.trim() || undefined;
165
+ }
166
+ const quotaBuckets = new Map();
167
+ /** 仅用于测试/运维:清空内存额度。 */
168
+ function resetFreeQuota() {
169
+ quotaBuckets.clear();
170
+ }
171
+ function quotaWindowMs(q) {
172
+ if (q.windowMs && q.windowMs > 0)
173
+ return q.windowMs;
174
+ return q.period === 'month' ? 30 * 24 * 3_600_000 : 24 * 3_600_000;
175
+ }
176
+ function consumeFreeQuota(opts, req, now) {
177
+ const q = opts.freeQuota;
178
+ const ns = opts.resource?.serviceName ?? opts.payTo ?? opts.accepts?.[0]?.payTo ?? 'x402';
179
+ const caller = q.key ? q.key(req) : req.ip ?? 'anonymous';
180
+ const key = `${ns}\u0000${caller}`;
181
+ const win = quotaWindowMs(q);
182
+ const bucket = quotaBuckets.get(key);
183
+ if (!bucket || now - bucket.windowStart >= win) {
184
+ quotaBuckets.set(key, { count: 1, windowStart: now });
185
+ return { allowed: q.calls > 0, remaining: Math.max(0, q.calls - 1) };
186
+ }
187
+ if (bucket.count >= q.calls)
188
+ return { allowed: false, remaining: 0 };
189
+ bucket.count += 1;
190
+ return { allowed: true, remaining: Math.max(0, q.calls - bucket.count) };
191
+ }
192
+ /**
193
+ * Keel 轨(legacy verifyOnly):客户端先广播转账,载荷里带 `txHash`。
194
+ * 这类请求走 `/x402/verify`(验链上凭证),而不是 `/x402/settle`(代广播)。
195
+ */
196
+ function legacyTxHash(header) {
197
+ try {
198
+ const body = decodeBase64Json(header);
199
+ const txHash = body?.payload?.txHash;
200
+ return typeof txHash === 'string' && /^0x[0-9a-fA-F]{64}$/.test(txHash) ? txHash : null;
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ }
206
+ function hasPlatformCredential(req, extra) {
207
+ return [...KEY_HEADERS, ...extra].some((h) => {
208
+ const v = req.header(h);
209
+ return typeof v === 'string' && v.trim().length > 0;
210
+ });
211
+ }
212
+ /**
213
+ * 本次调用的价格(最小单位)。
214
+ * 优先级:`amountFor(path)` > 顶层 `amount` > 第一个带价格的 accept。
215
+ * 只配 `accepts`(多币种)而不写顶层 `amount` 是文档里的标准用法,必须从 accept 取价,
216
+ * 否则 `amount` 为 undefined 会命中下面的「不收费」分支,等于整站免费。
217
+ */
218
+ function resolveAmount(opts) {
219
+ return opts.amount ?? opts.accepts?.find((a) => a.amount)?.amount;
220
+ }
221
+ /**
222
+ * 网关核心:返回「放行」或「402 挑战」,与框架无关。
223
+ * 结算失败一律 402(携带原因),避免把上游错误变成 500。
224
+ */
225
+ async function authorizeX402(opts, req) {
226
+ const exempt = [...DEFAULT_EXEMPT, ...(opts.exempt ?? [])];
227
+ if (exempt.some((e) => req.path === e || req.path.startsWith(e)))
228
+ return { action: 'pass' };
229
+ if (hasPlatformCredential(req, opts.passthroughHeaders ?? []))
230
+ return { action: 'pass' };
231
+ const amount = opts.amountFor ? opts.amountFor(req.path) : resolveAmount(opts);
232
+ // `amountFor` 返回 null = 该路径明确免费;但「压根没配价格」必须报错,
233
+ // 不能静默放行(否则漏配一个 amount 就等于整站免费)。
234
+ if (amount === null)
235
+ return { action: 'pass' };
236
+ if (!amount)
237
+ throw new Error('x402 gate: no price configured (set `amount` or a priced accept)');
238
+ const signature = paymentSignature(req);
239
+ // 免费额度:只对「本来要收费、且没带付款凭证」的请求计数;已付款的正常走结算。
240
+ if (opts.freeQuota && !signature) {
241
+ const now = opts.freeQuota.now?.() ?? Date.now();
242
+ const quota = consumeFreeQuota(opts, req, now);
243
+ if (quota.allowed) {
244
+ return { action: 'pass', headers: { 'X-Free-Quota-Remaining': String(quota.remaining) } };
245
+ }
246
+ }
247
+ const challenge = buildChallenge(opts, req.url, amount, req.path);
248
+ const required = () => ({
249
+ 'Content-Type': 'application/json',
250
+ 'PAYMENT-REQUIRED': encodeBase64Json(challenge),
251
+ });
252
+ if (!signature) {
253
+ challenge.error = opts.freeQuota
254
+ ? 'free quota exhausted; payment signature is required (X-PAYMENT or PAYMENT-SIGNATURE)'
255
+ : 'payment signature is required (X-PAYMENT or PAYMENT-SIGNATURE)';
256
+ return { action: 'challenge', status: 402, headers: required(), body: toV1Challenge(challenge) };
257
+ }
258
+ const base = opts.paymentsUrl.replace(/\/+$/, '');
259
+ const txHash = legacyTxHash(signature);
260
+ try {
261
+ const resp = await fetch(`${base}${txHash ? '/x402/verify' : '/x402/settle'}`, {
262
+ method: 'POST',
263
+ headers: {
264
+ 'Content-Type': 'application/json',
265
+ ...(opts.paymentsKey ? { Authorization: `Bearer ${opts.paymentsKey}` } : {}),
266
+ },
267
+ // 转发完整挑战:引擎按载荷在 `accepts[]` 中匹配(EIP-3009 / Permit2)。
268
+ body: JSON.stringify({ paymentPayload: signature, paymentRequirements: challenge }),
269
+ signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout
270
+ ? AbortSignal.timeout(opts.timeoutMs ?? 15_000)
271
+ : undefined,
272
+ });
273
+ const result = (await resp.json().catch(() => ({})));
274
+ // Keel 轨:交易已由客户端广播,验通过即放行;回执带客户端给的 txHash。
275
+ if (txHash) {
276
+ if (!resp.ok || result.isValid !== true) {
277
+ challenge.error = result.invalidReason || result.errorReason || result.message || `verification failed (${resp.status})`;
278
+ opts.log?.(`x402 gate: verify rejected ${req.path} → ${challenge.error}`);
279
+ return { action: 'challenge', status: 402, headers: required(), body: toV1Challenge(challenge) };
280
+ }
281
+ const receipt = {
282
+ success: true,
283
+ transaction: txHash,
284
+ network: result.network ?? challenge.accepts[0]?.network,
285
+ payer: result.payer,
286
+ amount,
287
+ };
288
+ return {
289
+ action: 'pass',
290
+ headers: {
291
+ 'PAYMENT-RESPONSE': encodeBase64Json(receipt),
292
+ // v1 回执头:老客户端只读这个,network 用短名
293
+ 'X-PAYMENT-RESPONSE': encodeBase64Json({ ...receipt, network: toV1Network(receipt.network ?? '') }),
294
+ },
295
+ };
296
+ }
297
+ if (!resp.ok || !result.success) {
298
+ challenge.error = result.errorReason || result.invalidReason || result.message || `settlement failed (${resp.status})`;
299
+ opts.log?.(`x402 gate: settle rejected ${req.path} → ${challenge.error}`);
300
+ return { action: 'challenge', status: 402, headers: required(), body: toV1Challenge(challenge) };
301
+ }
302
+ const receipt = {
303
+ success: true,
304
+ transaction: result.transaction,
305
+ network: result.network,
306
+ payer: result.payer,
307
+ amount: result.amount ?? amount,
308
+ };
309
+ return {
310
+ action: 'pass',
311
+ headers: {
312
+ 'PAYMENT-RESPONSE': encodeBase64Json(receipt),
313
+ // v1 回执头:老客户端只读这个,network 用短名
314
+ 'X-PAYMENT-RESPONSE': encodeBase64Json({ ...receipt, network: toV1Network(receipt.network ?? '') }),
315
+ },
316
+ };
317
+ }
318
+ catch (err) {
319
+ const message = err instanceof Error ? err.message : 'settlement unavailable';
320
+ opts.log?.(`x402 gate: settle error ${req.path} → ${message}`);
321
+ challenge.error = 'payment settlement unavailable, retry shortly';
322
+ return { action: 'challenge', status: 402, headers: required(), body: toV1Challenge(challenge) };
323
+ }
324
+ }
325
+ /** Express / Connect 适配器 */
326
+ function createX402Gate(opts) {
327
+ return async function x402Gate(req, res, next) {
328
+ const path = req.path || '/';
329
+ const url = `${req.protocol || 'https'}://${req.get?.('host') || 'localhost'}${req.originalUrl || path}`;
330
+ const result = await authorizeX402(opts, {
331
+ path,
332
+ url,
333
+ ip: typeof req.ip === 'string' ? req.ip : undefined,
334
+ header: (name) => {
335
+ const v = req.headers?.[name];
336
+ return Array.isArray(v) ? v[0] ?? null : v ?? null;
337
+ },
338
+ });
339
+ if (result.action === 'pass') {
340
+ for (const [k, v] of Object.entries(result.headers ?? {}))
341
+ res.set(k, v);
342
+ next();
343
+ return;
344
+ }
345
+ for (const [k, v] of Object.entries(result.headers))
346
+ res.set(k, v);
347
+ res.status(result.status).json(result.body);
348
+ };
349
+ }
350
+ /**
351
+ * 包装一个 Fetch 处理器:未付款 → 402;已付款/有平台凭证 → 原处理器。
352
+ *
353
+ * ```ts
354
+ * // app/api/quote/route.ts
355
+ * export const GET = withX402(opts, async () => Response.json({ price: 42 }))
356
+ * ```
357
+ */
358
+ function withX402(opts, handler) {
359
+ return async (request) => {
360
+ const url = request.url;
361
+ const parsed = new URL(url);
362
+ const result = await authorizeX402(opts, {
363
+ path: parsed.pathname,
364
+ url,
365
+ ip: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || undefined,
366
+ header: (name) => request.headers.get(name),
367
+ });
368
+ if (result.action === 'pass') {
369
+ const response = await handler(request);
370
+ for (const [k, v] of Object.entries(result.headers ?? {}))
371
+ response.headers.set(k, v);
372
+ return response;
373
+ }
374
+ return new Response(JSON.stringify(result.body), {
375
+ status: result.status,
376
+ headers: result.headers,
377
+ });
378
+ };
379
+ }
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@keelcodes/x402-gate",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Keel x402 v2 resource gate — Express / Fetch / Next.js adapters (no keys, no chain access)",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "keywords": [
27
+ "x402",
28
+ "payment",
29
+ "express",
30
+ "nextjs",
31
+ "fetch",
32
+ "agent"
33
+ ],
34
+ "license": "MIT",
35
+ "author": "Keel",
36
+ "devDependencies": {
37
+ "typescript": "^5.0.0"
38
+ }
39
+ }