@ch4p/plugin-x402 0.1.6 → 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 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
- * Payment amount in the asset's smallest unit.
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: serverCfg.amount,
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.1.6",
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.1.6"
17
+ "@ch4p/core": "0.2.0"
18
18
  },
19
19
  "scripts": {
20
20
  "build": "tsup src/index.ts --format esm --dts",
@@ -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: serverCfg.amount,
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
- * Payment amount in the asset's smallest unit.
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