@ch4p/plugin-x402 0.1.5 → 0.2.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/dist/index.d.ts +31 -1
- package/dist/index.js +5 -2
- package/package.json +2 -2
- package/src/middleware.test.ts +108 -0
- package/src/middleware.ts +8 -2
- package/src/types.ts +32 -1
package/dist/index.d.ts
CHANGED
|
@@ -72,6 +72,26 @@ interface X402PaymentPayload {
|
|
|
72
72
|
authorization: X402PaymentAuthorization;
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Per-route payment override for a specific path pattern.
|
|
77
|
+
* When a request matches this pattern (using the same rules as `protectedPaths`),
|
|
78
|
+
* the route's `amount` and optional `description` take precedence over the
|
|
79
|
+
* global `X402ServerConfig` values.
|
|
80
|
+
*/
|
|
81
|
+
interface X402RouteConfig {
|
|
82
|
+
/**
|
|
83
|
+
* URL path pattern to match. Supports the same syntax as `protectedPaths`:
|
|
84
|
+
* exact match ("/sessions"), wildcard prefix ("/webhooks/*"), or catch-all ("/*").
|
|
85
|
+
*/
|
|
86
|
+
path: string;
|
|
87
|
+
/**
|
|
88
|
+
* Amount in the asset's smallest unit for requests matching this route.
|
|
89
|
+
* Overrides the global `amount` for this path.
|
|
90
|
+
*/
|
|
91
|
+
amount: string;
|
|
92
|
+
/** Human-readable description override for this route's 402 response. */
|
|
93
|
+
description?: string;
|
|
94
|
+
}
|
|
75
95
|
/**
|
|
76
96
|
* Server-side x402 protection configuration.
|
|
77
97
|
*/
|
|
@@ -79,8 +99,9 @@ interface X402ServerConfig {
|
|
|
79
99
|
/** Wallet address that receives payments. */
|
|
80
100
|
payTo: string;
|
|
81
101
|
/**
|
|
82
|
-
*
|
|
102
|
+
* Global payment amount in the asset's smallest unit.
|
|
83
103
|
* Example: "1000000" = 1 USDC (6 decimals).
|
|
104
|
+
* Individual routes can override this via `routes`.
|
|
84
105
|
*/
|
|
85
106
|
amount: string;
|
|
86
107
|
/**
|
|
@@ -103,6 +124,15 @@ interface X402ServerConfig {
|
|
|
103
124
|
protectedPaths?: string[];
|
|
104
125
|
/** Seconds before a payment authorization expires. Default: 300. */
|
|
105
126
|
maxTimeoutSeconds?: number;
|
|
127
|
+
/**
|
|
128
|
+
* Per-route pricing overrides.
|
|
129
|
+
* Each entry matches a path pattern and supplies an `amount` (and optional
|
|
130
|
+
* `description`) that replaces the global values for requests on that path.
|
|
131
|
+
* Routes are checked in order; the first match wins.
|
|
132
|
+
* Paths still need to appear in `protectedPaths` (or the default "/*") to
|
|
133
|
+
* be gated at all — `routes` only changes the *price*, not the *gating*.
|
|
134
|
+
*/
|
|
135
|
+
routes?: X402RouteConfig[];
|
|
106
136
|
/**
|
|
107
137
|
* Optional payment verifier. Called with the decoded payment and the
|
|
108
138
|
* active requirements after the X-PAYMENT header passes structural
|
package/dist/index.js
CHANGED
|
@@ -69,12 +69,15 @@ function createX402Middleware(config) {
|
|
|
69
69
|
if (PUBLIC_PATHS.has(urlPath)) return false;
|
|
70
70
|
const isProtected = protectedPaths.some((p) => pathMatches(urlPath, p));
|
|
71
71
|
if (!isProtected) return false;
|
|
72
|
+
const matchedRoute = serverCfg.routes?.find((r) => pathMatches(urlPath, r.path));
|
|
73
|
+
const effectiveAmount = matchedRoute?.amount ?? serverCfg.amount;
|
|
74
|
+
const effectiveDescription = matchedRoute?.description ?? description;
|
|
72
75
|
const requirements = {
|
|
73
76
|
scheme: "exact",
|
|
74
77
|
network,
|
|
75
|
-
maxAmountRequired:
|
|
78
|
+
maxAmountRequired: effectiveAmount,
|
|
76
79
|
resource: urlPath,
|
|
77
|
-
description,
|
|
80
|
+
description: effectiveDescription,
|
|
78
81
|
mimeType: "application/json",
|
|
79
82
|
payTo: serverCfg.payTo,
|
|
80
83
|
maxTimeoutSeconds,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ch4p/plugin-x402",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "x402 HTTP micropayment plugin for ch4p — server middleware and agent payment tool",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"ethers": "^6.14.0",
|
|
17
|
-
"@ch4p/core": "0.
|
|
17
|
+
"@ch4p/core": "0.2.0"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "tsup src/index.ts --format esm --dts",
|
package/src/middleware.test.ts
CHANGED
|
@@ -295,6 +295,114 @@ describe('createX402Middleware — valid payment', () => {
|
|
|
295
295
|
});
|
|
296
296
|
});
|
|
297
297
|
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// createX402Middleware — per-route pricing
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
describe('createX402Middleware — per-route pricing', () => {
|
|
303
|
+
const ROUTE_CONFIG: X402Config = {
|
|
304
|
+
enabled: true,
|
|
305
|
+
server: {
|
|
306
|
+
payTo: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
307
|
+
amount: '1000000', // global: 1 USDC
|
|
308
|
+
description: 'Global description',
|
|
309
|
+
protectedPaths: ['/sessions', '/sessions/*', '/webhooks/*'],
|
|
310
|
+
routes: [
|
|
311
|
+
{ path: '/sessions', amount: '500000', description: 'Session route' },
|
|
312
|
+
{ path: '/sessions/*', amount: '250000' },
|
|
313
|
+
{ path: '/webhooks/*', amount: '2000000', description: 'Webhook route' },
|
|
314
|
+
],
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
it('route-specific amount overrides global amount', async () => {
|
|
319
|
+
const handler = createX402Middleware(ROUTE_CONFIG)!;
|
|
320
|
+
const res = makeRes();
|
|
321
|
+
await handler(makeReq('/sessions'), res as unknown as ServerResponse);
|
|
322
|
+
expect(res.statusCode).toBe(402);
|
|
323
|
+
const body = JSON.parse(res.body);
|
|
324
|
+
expect(body.accepts[0].maxAmountRequired).toBe('500000');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('route-specific description overrides global description', async () => {
|
|
328
|
+
const handler = createX402Middleware(ROUTE_CONFIG)!;
|
|
329
|
+
const res = makeRes();
|
|
330
|
+
await handler(makeReq('/sessions'), res as unknown as ServerResponse);
|
|
331
|
+
const body = JSON.parse(res.body);
|
|
332
|
+
expect(body.accepts[0].description).toBe('Session route');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('wildcard route matches sub-path with its amount', async () => {
|
|
336
|
+
const handler = createX402Middleware(ROUTE_CONFIG)!;
|
|
337
|
+
const res = makeRes();
|
|
338
|
+
await handler(makeReq('/sessions/abc'), res as unknown as ServerResponse);
|
|
339
|
+
const body = JSON.parse(res.body);
|
|
340
|
+
expect(body.accepts[0].maxAmountRequired).toBe('250000');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('falls back to global description when route has none', async () => {
|
|
344
|
+
const handler = createX402Middleware(ROUTE_CONFIG)!;
|
|
345
|
+
const res = makeRes();
|
|
346
|
+
await handler(makeReq('/sessions/abc'), res as unknown as ServerResponse);
|
|
347
|
+
const body = JSON.parse(res.body);
|
|
348
|
+
expect(body.accepts[0].description).toBe('Global description');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('path not in routes falls back to global amount', async () => {
|
|
352
|
+
const cfg: X402Config = {
|
|
353
|
+
enabled: true,
|
|
354
|
+
server: {
|
|
355
|
+
payTo: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
356
|
+
amount: '1000000',
|
|
357
|
+
protectedPaths: ['/other'],
|
|
358
|
+
routes: [{ path: '/sessions', amount: '500000' }],
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
const handler = createX402Middleware(cfg)!;
|
|
362
|
+
const res = makeRes();
|
|
363
|
+
await handler(makeReq('/other'), res as unknown as ServerResponse);
|
|
364
|
+
const body = JSON.parse(res.body);
|
|
365
|
+
expect(body.accepts[0].maxAmountRequired).toBe('1000000');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('routes: [] (empty) falls back to global amount', async () => {
|
|
369
|
+
const cfg: X402Config = {
|
|
370
|
+
enabled: true,
|
|
371
|
+
server: {
|
|
372
|
+
payTo: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
373
|
+
amount: '1000000',
|
|
374
|
+
protectedPaths: ['/sessions'],
|
|
375
|
+
routes: [],
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
const handler = createX402Middleware(cfg)!;
|
|
379
|
+
const res = makeRes();
|
|
380
|
+
await handler(makeReq('/sessions'), res as unknown as ServerResponse);
|
|
381
|
+
const body = JSON.parse(res.body);
|
|
382
|
+
expect(body.accepts[0].maxAmountRequired).toBe('1000000');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('first matching route wins when multiple routes could match', async () => {
|
|
386
|
+
const cfg: X402Config = {
|
|
387
|
+
enabled: true,
|
|
388
|
+
server: {
|
|
389
|
+
payTo: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
390
|
+
amount: '1000000',
|
|
391
|
+
protectedPaths: ['/api/*'],
|
|
392
|
+
routes: [
|
|
393
|
+
{ path: '/api/*', amount: '100000' },
|
|
394
|
+
{ path: '/api/data', amount: '999999' }, // should never be reached for /api/data
|
|
395
|
+
],
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
const handler = createX402Middleware(cfg)!;
|
|
399
|
+
const res = makeRes();
|
|
400
|
+
await handler(makeReq('/api/data'), res as unknown as ServerResponse);
|
|
401
|
+
const body = JSON.parse(res.body);
|
|
402
|
+
expect(body.accepts[0].maxAmountRequired).toBe('100000');
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
298
406
|
// ---------------------------------------------------------------------------
|
|
299
407
|
// createX402Middleware — defaults
|
|
300
408
|
// ---------------------------------------------------------------------------
|
package/src/middleware.ts
CHANGED
|
@@ -141,12 +141,18 @@ export function createX402Middleware(config: X402Config): X402PreHandler | null
|
|
|
141
141
|
const isProtected = protectedPaths.some((p) => pathMatches(urlPath, p));
|
|
142
142
|
if (!isProtected) return false;
|
|
143
143
|
|
|
144
|
+
// Per-route pricing: find the first route whose path pattern matches.
|
|
145
|
+
const matchedRoute = serverCfg.routes?.find((r) => pathMatches(urlPath, r.path));
|
|
146
|
+
const effectiveAmount = matchedRoute?.amount ?? serverCfg.amount;
|
|
147
|
+
const effectiveDescription =
|
|
148
|
+
matchedRoute?.description ?? description;
|
|
149
|
+
|
|
144
150
|
const requirements: X402PaymentRequirements = {
|
|
145
151
|
scheme: 'exact',
|
|
146
152
|
network,
|
|
147
|
-
maxAmountRequired:
|
|
153
|
+
maxAmountRequired: effectiveAmount,
|
|
148
154
|
resource: urlPath,
|
|
149
|
-
description,
|
|
155
|
+
description: effectiveDescription,
|
|
150
156
|
mimeType: 'application/json',
|
|
151
157
|
payTo: serverCfg.payTo,
|
|
152
158
|
maxTimeoutSeconds,
|
package/src/types.ts
CHANGED
|
@@ -74,6 +74,27 @@ export interface X402PaymentPayload {
|
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Per-route payment override for a specific path pattern.
|
|
79
|
+
* When a request matches this pattern (using the same rules as `protectedPaths`),
|
|
80
|
+
* the route's `amount` and optional `description` take precedence over the
|
|
81
|
+
* global `X402ServerConfig` values.
|
|
82
|
+
*/
|
|
83
|
+
export interface X402RouteConfig {
|
|
84
|
+
/**
|
|
85
|
+
* URL path pattern to match. Supports the same syntax as `protectedPaths`:
|
|
86
|
+
* exact match ("/sessions"), wildcard prefix ("/webhooks/*"), or catch-all ("/*").
|
|
87
|
+
*/
|
|
88
|
+
path: string;
|
|
89
|
+
/**
|
|
90
|
+
* Amount in the asset's smallest unit for requests matching this route.
|
|
91
|
+
* Overrides the global `amount` for this path.
|
|
92
|
+
*/
|
|
93
|
+
amount: string;
|
|
94
|
+
/** Human-readable description override for this route's 402 response. */
|
|
95
|
+
description?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
77
98
|
/**
|
|
78
99
|
* Server-side x402 protection configuration.
|
|
79
100
|
*/
|
|
@@ -81,8 +102,9 @@ export interface X402ServerConfig {
|
|
|
81
102
|
/** Wallet address that receives payments. */
|
|
82
103
|
payTo: string;
|
|
83
104
|
/**
|
|
84
|
-
*
|
|
105
|
+
* Global payment amount in the asset's smallest unit.
|
|
85
106
|
* Example: "1000000" = 1 USDC (6 decimals).
|
|
107
|
+
* Individual routes can override this via `routes`.
|
|
86
108
|
*/
|
|
87
109
|
amount: string;
|
|
88
110
|
/**
|
|
@@ -105,6 +127,15 @@ export interface X402ServerConfig {
|
|
|
105
127
|
protectedPaths?: string[];
|
|
106
128
|
/** Seconds before a payment authorization expires. Default: 300. */
|
|
107
129
|
maxTimeoutSeconds?: number;
|
|
130
|
+
/**
|
|
131
|
+
* Per-route pricing overrides.
|
|
132
|
+
* Each entry matches a path pattern and supplies an `amount` (and optional
|
|
133
|
+
* `description`) that replaces the global values for requests on that path.
|
|
134
|
+
* Routes are checked in order; the first match wins.
|
|
135
|
+
* Paths still need to appear in `protectedPaths` (or the default "/*") to
|
|
136
|
+
* be gated at all — `routes` only changes the *price*, not the *gating*.
|
|
137
|
+
*/
|
|
138
|
+
routes?: X402RouteConfig[];
|
|
108
139
|
/**
|
|
109
140
|
* Optional payment verifier. Called with the decoded payment and the
|
|
110
141
|
* active requirements after the X-PAYMENT header passes structural
|