@agentcash/router 0.2.2 → 0.3.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
@@ -18,6 +18,36 @@ pnpm add next zod @x402/core @x402/evm @x402/extensions @coinbase/x402 zod-opena
18
18
  pnpm add mpay
19
19
  ```
20
20
 
21
+ ## Environment Setup
22
+
23
+ The router uses the default facilitator from `@coinbase/x402` for x402 payments, which requires CDP API keys:
24
+
25
+ ```bash
26
+ CDP_API_KEY_ID=your-key-id
27
+ CDP_API_KEY_SECRET=your-key-secret
28
+ ```
29
+
30
+ **For Next.js apps with env validation** (T3 stack, `@t3-oss/env-nextjs`): Add these to your env schema — Next.js doesn't expose undeclared env vars to `process.env`.
31
+
32
+ ```typescript
33
+ // src/env.js
34
+ import { createEnv } from "@t3-oss/env-nextjs";
35
+ import { z } from "zod";
36
+
37
+ export const env = createEnv({
38
+ server: {
39
+ CDP_API_KEY_ID: z.string(),
40
+ CDP_API_KEY_SECRET: z.string(),
41
+ },
42
+ runtimeEnv: {
43
+ CDP_API_KEY_ID: process.env.CDP_API_KEY_ID,
44
+ CDP_API_KEY_SECRET: process.env.CDP_API_KEY_SECRET,
45
+ },
46
+ });
47
+ ```
48
+
49
+ Without these keys, x402 routes will fail to initialize (empty 402 responses, no payment header).
50
+
21
51
  ## Quick Start
22
52
 
23
53
  ### 1. Create the router (once per service)
@@ -111,11 +141,28 @@ The fluent builder ensures compile-time safety:
111
141
 
112
142
  ### Pricing Modes
113
143
 
114
- **Static**: `router.route('search').paid('0.02')`
144
+ **Static** - Fixed price for all requests:
145
+ ```typescript
146
+ router.route('search').paid('0.02')
147
+ ```
115
148
 
116
- **Dynamic**: `router.route('gen').paid((body) => calculateCost(body), { maxPrice: '5.00' }).body(schema)`
149
+ **Dynamic** - Calculate price based on request body:
150
+ ```typescript
151
+ router.route('gen')
152
+ .paid((body) => calculateCost(body.imageSize, body.quality))
153
+ .body(imageGenSchema)
154
+ .handler(async ({ body }) => generate(body));
155
+ ```
117
156
 
118
- **Tiered**:
157
+ **Dynamic with safety net** - Cap at maxPrice if calculation exceeds, fallback to maxPrice on errors:
158
+ ```typescript
159
+ router.route('compute')
160
+ .paid((body) => calculateExpensiveOperation(body), { maxPrice: '10.00' })
161
+ .body(computeSchema)
162
+ .handler(async ({ body }) => compute(body));
163
+ ```
164
+
165
+ **Tiered** - Price based on a specific field value:
119
166
  ```typescript
120
167
  router.route('upload').paid({
121
168
  field: 'tier',
@@ -123,7 +170,38 @@ router.route('upload').paid({
123
170
  '10mb': { price: '0.02', label: '10 MB' },
124
171
  '100mb': { price: '0.20', label: '100 MB' },
125
172
  },
126
- }).body(schema)
173
+ }).body(uploadSchema)
174
+ ```
175
+
176
+ #### maxPrice Semantics (v0.3.1+)
177
+
178
+ `maxPrice` is **optional** for dynamic pricing and acts as a safety net:
179
+
180
+ 1. **Capping**: If `calculateCost(body)` returns `"15.00"` but `maxPrice: "10.00"`, the client is charged `$10.00` (capped) and a warning alert fires.
181
+
182
+ 2. **Fallback**: If `calculateCost(body)` throws an error and `maxPrice` is set, the route falls back to `maxPrice` (degraded mode) and an alert fires. Without `maxPrice`, the route returns 500.
183
+
184
+ 3. **Trust mode**: No `maxPrice` means full trust in your pricing function (no cap, no fallback).
185
+
186
+ **Best practices:**
187
+ - ✅ Always set `maxPrice` for production routes (safety net)
188
+ - ✅ Use `maxPrice` for routes with external dependencies (pricing APIs)
189
+ - ✅ Monitor alerts for capping events (indicates pricing bug)
190
+ - ⚠️ Skip `maxPrice` only for well-tested, unbounded pricing (e.g., per-GB storage)
191
+
192
+ **Example with safety net:**
193
+ ```typescript
194
+ router.route('ai-gen')
195
+ .paid(async (body) => {
196
+ // External pricing API (can fail)
197
+ const res = await fetch('https://pricing.example.com/calculate', {
198
+ method: 'POST',
199
+ body: JSON.stringify(body),
200
+ });
201
+ return res.json().price;
202
+ }, { maxPrice: '5.00' }) // Fallback if API is down
203
+ .body(genSchema)
204
+ .handler(async ({ body }) => generate(body));
127
205
  ```
128
206
 
129
207
  ### Dual Protocol (x402 + MPP)
package/dist/index.cjs CHANGED
@@ -523,7 +523,7 @@ function createRequestHandler(routeEntry, handler, deps) {
523
523
  if (routeEntry.authMode === "unprotected") {
524
524
  return handleAuth(null, void 0);
525
525
  }
526
- let account = void 0;
526
+ let account;
527
527
  if (routeEntry.authMode === "apiKey" || routeEntry.apiKeyResolver) {
528
528
  if (!routeEntry.apiKeyResolver) {
529
529
  return fail(401, "API key resolver not configured", meta, pluginCtx);
@@ -538,6 +538,16 @@ function createRequestHandler(routeEntry, handler, deps) {
538
538
  }
539
539
  }
540
540
  const protocol = detectProtocol(request);
541
+ let earlyBodyData;
542
+ if (!protocol && typeof routeEntry.pricing === "function" && routeEntry.bodySchema) {
543
+ const requestForPricing = request.clone();
544
+ const earlyBodyResult = await parseBody(requestForPricing, routeEntry);
545
+ if (!earlyBodyResult.ok) {
546
+ firePluginResponse(deps, pluginCtx, meta, earlyBodyResult.response);
547
+ return earlyBodyResult.response;
548
+ }
549
+ earlyBodyData = earlyBodyResult.data;
550
+ }
541
551
  if (routeEntry.authMode === "siwx") {
542
552
  if (!request.headers.get("SIGN-IN-WITH-X")) {
543
553
  const url = new URL(request.url);
@@ -606,7 +616,7 @@ function createRequestHandler(routeEntry, handler, deps) {
606
616
  return handleAuth(siwx.wallet, void 0);
607
617
  }
608
618
  if (!protocol || protocol === "siwx") {
609
- return await build402(request, routeEntry, deps, meta, pluginCtx);
619
+ return await build402(request, routeEntry, deps, meta, pluginCtx, earlyBodyData);
610
620
  }
611
621
  const body = await parseBody(request, routeEntry);
612
622
  if (!body.ok) {
@@ -744,10 +754,55 @@ function parseQuery(request, routeEntry) {
744
754
  const result = routeEntry.querySchema.safeParse(params);
745
755
  return result.success ? result.data : params;
746
756
  }
747
- async function build402(request, routeEntry, deps, meta, pluginCtx) {
757
+ async function resolveDynamicPrice(bodyData, routeEntry, deps, pluginCtx, meta) {
758
+ try {
759
+ let price = await resolvePrice(routeEntry.pricing, bodyData);
760
+ if (routeEntry.maxPrice) {
761
+ const calculated = parseFloat(price);
762
+ const max = parseFloat(routeEntry.maxPrice);
763
+ if (calculated > max) {
764
+ firePluginHook(deps.plugin, "onAlert", pluginCtx, {
765
+ level: "warn",
766
+ message: `Price ${price} exceeds maxPrice ${routeEntry.maxPrice}, capping`,
767
+ route: routeEntry.key,
768
+ meta: { calculated: price, maxPrice: routeEntry.maxPrice, body: bodyData }
769
+ });
770
+ price = routeEntry.maxPrice;
771
+ }
772
+ }
773
+ return { price };
774
+ } catch (err) {
775
+ firePluginHook(deps.plugin, "onAlert", pluginCtx, {
776
+ level: "error",
777
+ message: `Pricing function failed: ${err instanceof Error ? err.message : String(err)}`,
778
+ route: routeEntry.key,
779
+ meta: { error: err instanceof Error ? err.stack : String(err), body: bodyData }
780
+ });
781
+ if (routeEntry.maxPrice) {
782
+ firePluginHook(deps.plugin, "onAlert", pluginCtx, {
783
+ level: "warn",
784
+ message: `Using maxPrice ${routeEntry.maxPrice} as fallback after pricing error`,
785
+ route: routeEntry.key
786
+ });
787
+ return { price: routeEntry.maxPrice };
788
+ } else {
789
+ const errorResponse = import_server2.NextResponse.json(
790
+ { success: false, error: "Price calculation failed" },
791
+ { status: 500 }
792
+ );
793
+ firePluginResponse(deps, pluginCtx, meta, errorResponse);
794
+ return { error: errorResponse };
795
+ }
796
+ }
797
+ }
798
+ async function build402(request, routeEntry, deps, meta, pluginCtx, bodyData) {
748
799
  const response = new import_server2.NextResponse(null, { status: 402 });
749
800
  let challengePrice;
750
- if (routeEntry.maxPrice) {
801
+ if (bodyData !== void 0 && typeof routeEntry.pricing === "function") {
802
+ const result = await resolveDynamicPrice(bodyData, routeEntry, deps, pluginCtx, meta);
803
+ if ("error" in result) return result.error;
804
+ challengePrice = result.price;
805
+ } else if (routeEntry.maxPrice) {
751
806
  challengePrice = routeEntry.maxPrice;
752
807
  } else if (routeEntry.pricing) {
753
808
  try {
@@ -908,9 +963,6 @@ var RouteBuilder = class {
908
963
  next._pricing = pricing;
909
964
  if (options?.protocols) next._protocols = options.protocols;
910
965
  if (options?.maxPrice) next._maxPrice = options.maxPrice;
911
- if (typeof pricing === "function" && !options?.maxPrice) {
912
- throw new Error(`route '${this._key}': dynamic pricing requires maxPrice option`);
913
- }
914
966
  if (typeof pricing === "object" && "tiers" in pricing) {
915
967
  for (const [tierKey, tierConfig] of Object.entries(pricing.tiers)) {
916
968
  if (!tierKey) {
@@ -1178,6 +1230,23 @@ function createRouter(config) {
1178
1230
  const nonceStore = config.siwx?.nonceStore ?? new MemoryNonceStore();
1179
1231
  const network = config.network ?? "eip155:8453";
1180
1232
  const baseUrl = typeof globalThis.process !== "undefined" ? process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000" : "http://localhost:3000";
1233
+ if (config.protocols) {
1234
+ if (config.protocols.length === 0) {
1235
+ throw new Error(
1236
+ "RouterConfig.protocols cannot be empty. Omit the field to use default ['x402'] or specify protocols explicitly."
1237
+ );
1238
+ }
1239
+ if (config.protocols.includes("mpp") && !config.mpp) {
1240
+ throw new Error(
1241
+ 'RouterConfig.protocols includes "mpp" but RouterConfig.mpp is not configured. Add mpp: { secretKey, currency, recipient } to your router config.'
1242
+ );
1243
+ }
1244
+ if (config.protocols.includes("x402") && !config.payeeAddress) {
1245
+ throw new Error(
1246
+ 'RouterConfig.protocols includes "x402" but RouterConfig.payeeAddress is not configured.'
1247
+ );
1248
+ }
1249
+ }
1181
1250
  if (config.plugin?.init) {
1182
1251
  try {
1183
1252
  const result = config.plugin.init({ origin: baseUrl });
@@ -1213,7 +1282,8 @@ function createRouter(config) {
1213
1282
  route(key) {
1214
1283
  const builder = new RouteBuilder(key, registry, deps);
1215
1284
  if (config.prices && key in config.prices) {
1216
- return builder.paid(config.prices[key]);
1285
+ const options = config.protocols ? { protocols: config.protocols } : void 0;
1286
+ return builder.paid(config.prices[key], options);
1217
1287
  }
1218
1288
  return builder;
1219
1289
  },
package/dist/index.d.cts CHANGED
@@ -184,6 +184,20 @@ interface RouterConfig {
184
184
  currency: string;
185
185
  recipient?: string;
186
186
  };
187
+ /**
188
+ * Payment protocols to accept on auto-priced routes (those using the `prices` config).
189
+ *
190
+ * @default ['x402']
191
+ *
192
+ * @example
193
+ * // Accept both x402 and MPP payments
194
+ * createRouter({
195
+ * protocols: ['x402', 'mpp'],
196
+ * mpp: { secretKey, currency, recipient },
197
+ * prices: { 'exa/search': '0.01' }
198
+ * })
199
+ */
200
+ protocols?: ProtocolType[];
187
201
  }
188
202
 
189
203
  declare class RouteRegistry {
@@ -251,7 +265,7 @@ declare class RouteBuilder<TBody = undefined, TQuery = undefined, HasAuth extend
251
265
  private fork;
252
266
  paid(pricing: string, options?: PaidOptions): RouteBuilder<TBody, TQuery, True, False, HasBody>;
253
267
  paid<TBodyIn>(pricing: (body: TBodyIn) => string | Promise<string>, options?: PaidOptions & {
254
- maxPrice: string;
268
+ maxPrice?: string;
255
269
  }): RouteBuilder<TBody, TQuery, True, True, HasBody>;
256
270
  paid(pricing: {
257
271
  field: string;
package/dist/index.d.ts CHANGED
@@ -184,6 +184,20 @@ interface RouterConfig {
184
184
  currency: string;
185
185
  recipient?: string;
186
186
  };
187
+ /**
188
+ * Payment protocols to accept on auto-priced routes (those using the `prices` config).
189
+ *
190
+ * @default ['x402']
191
+ *
192
+ * @example
193
+ * // Accept both x402 and MPP payments
194
+ * createRouter({
195
+ * protocols: ['x402', 'mpp'],
196
+ * mpp: { secretKey, currency, recipient },
197
+ * prices: { 'exa/search': '0.01' }
198
+ * })
199
+ */
200
+ protocols?: ProtocolType[];
187
201
  }
188
202
 
189
203
  declare class RouteRegistry {
@@ -251,7 +265,7 @@ declare class RouteBuilder<TBody = undefined, TQuery = undefined, HasAuth extend
251
265
  private fork;
252
266
  paid(pricing: string, options?: PaidOptions): RouteBuilder<TBody, TQuery, True, False, HasBody>;
253
267
  paid<TBodyIn>(pricing: (body: TBodyIn) => string | Promise<string>, options?: PaidOptions & {
254
- maxPrice: string;
268
+ maxPrice?: string;
255
269
  }): RouteBuilder<TBody, TQuery, True, True, HasBody>;
256
270
  paid(pricing: {
257
271
  field: string;
package/dist/index.js CHANGED
@@ -489,7 +489,7 @@ function createRequestHandler(routeEntry, handler, deps) {
489
489
  if (routeEntry.authMode === "unprotected") {
490
490
  return handleAuth(null, void 0);
491
491
  }
492
- let account = void 0;
492
+ let account;
493
493
  if (routeEntry.authMode === "apiKey" || routeEntry.apiKeyResolver) {
494
494
  if (!routeEntry.apiKeyResolver) {
495
495
  return fail(401, "API key resolver not configured", meta, pluginCtx);
@@ -504,6 +504,16 @@ function createRequestHandler(routeEntry, handler, deps) {
504
504
  }
505
505
  }
506
506
  const protocol = detectProtocol(request);
507
+ let earlyBodyData;
508
+ if (!protocol && typeof routeEntry.pricing === "function" && routeEntry.bodySchema) {
509
+ const requestForPricing = request.clone();
510
+ const earlyBodyResult = await parseBody(requestForPricing, routeEntry);
511
+ if (!earlyBodyResult.ok) {
512
+ firePluginResponse(deps, pluginCtx, meta, earlyBodyResult.response);
513
+ return earlyBodyResult.response;
514
+ }
515
+ earlyBodyData = earlyBodyResult.data;
516
+ }
507
517
  if (routeEntry.authMode === "siwx") {
508
518
  if (!request.headers.get("SIGN-IN-WITH-X")) {
509
519
  const url = new URL(request.url);
@@ -572,7 +582,7 @@ function createRequestHandler(routeEntry, handler, deps) {
572
582
  return handleAuth(siwx.wallet, void 0);
573
583
  }
574
584
  if (!protocol || protocol === "siwx") {
575
- return await build402(request, routeEntry, deps, meta, pluginCtx);
585
+ return await build402(request, routeEntry, deps, meta, pluginCtx, earlyBodyData);
576
586
  }
577
587
  const body = await parseBody(request, routeEntry);
578
588
  if (!body.ok) {
@@ -710,10 +720,55 @@ function parseQuery(request, routeEntry) {
710
720
  const result = routeEntry.querySchema.safeParse(params);
711
721
  return result.success ? result.data : params;
712
722
  }
713
- async function build402(request, routeEntry, deps, meta, pluginCtx) {
723
+ async function resolveDynamicPrice(bodyData, routeEntry, deps, pluginCtx, meta) {
724
+ try {
725
+ let price = await resolvePrice(routeEntry.pricing, bodyData);
726
+ if (routeEntry.maxPrice) {
727
+ const calculated = parseFloat(price);
728
+ const max = parseFloat(routeEntry.maxPrice);
729
+ if (calculated > max) {
730
+ firePluginHook(deps.plugin, "onAlert", pluginCtx, {
731
+ level: "warn",
732
+ message: `Price ${price} exceeds maxPrice ${routeEntry.maxPrice}, capping`,
733
+ route: routeEntry.key,
734
+ meta: { calculated: price, maxPrice: routeEntry.maxPrice, body: bodyData }
735
+ });
736
+ price = routeEntry.maxPrice;
737
+ }
738
+ }
739
+ return { price };
740
+ } catch (err) {
741
+ firePluginHook(deps.plugin, "onAlert", pluginCtx, {
742
+ level: "error",
743
+ message: `Pricing function failed: ${err instanceof Error ? err.message : String(err)}`,
744
+ route: routeEntry.key,
745
+ meta: { error: err instanceof Error ? err.stack : String(err), body: bodyData }
746
+ });
747
+ if (routeEntry.maxPrice) {
748
+ firePluginHook(deps.plugin, "onAlert", pluginCtx, {
749
+ level: "warn",
750
+ message: `Using maxPrice ${routeEntry.maxPrice} as fallback after pricing error`,
751
+ route: routeEntry.key
752
+ });
753
+ return { price: routeEntry.maxPrice };
754
+ } else {
755
+ const errorResponse = NextResponse2.json(
756
+ { success: false, error: "Price calculation failed" },
757
+ { status: 500 }
758
+ );
759
+ firePluginResponse(deps, pluginCtx, meta, errorResponse);
760
+ return { error: errorResponse };
761
+ }
762
+ }
763
+ }
764
+ async function build402(request, routeEntry, deps, meta, pluginCtx, bodyData) {
714
765
  const response = new NextResponse2(null, { status: 402 });
715
766
  let challengePrice;
716
- if (routeEntry.maxPrice) {
767
+ if (bodyData !== void 0 && typeof routeEntry.pricing === "function") {
768
+ const result = await resolveDynamicPrice(bodyData, routeEntry, deps, pluginCtx, meta);
769
+ if ("error" in result) return result.error;
770
+ challengePrice = result.price;
771
+ } else if (routeEntry.maxPrice) {
717
772
  challengePrice = routeEntry.maxPrice;
718
773
  } else if (routeEntry.pricing) {
719
774
  try {
@@ -874,9 +929,6 @@ var RouteBuilder = class {
874
929
  next._pricing = pricing;
875
930
  if (options?.protocols) next._protocols = options.protocols;
876
931
  if (options?.maxPrice) next._maxPrice = options.maxPrice;
877
- if (typeof pricing === "function" && !options?.maxPrice) {
878
- throw new Error(`route '${this._key}': dynamic pricing requires maxPrice option`);
879
- }
880
932
  if (typeof pricing === "object" && "tiers" in pricing) {
881
933
  for (const [tierKey, tierConfig] of Object.entries(pricing.tiers)) {
882
934
  if (!tierKey) {
@@ -1144,6 +1196,23 @@ function createRouter(config) {
1144
1196
  const nonceStore = config.siwx?.nonceStore ?? new MemoryNonceStore();
1145
1197
  const network = config.network ?? "eip155:8453";
1146
1198
  const baseUrl = typeof globalThis.process !== "undefined" ? process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000" : "http://localhost:3000";
1199
+ if (config.protocols) {
1200
+ if (config.protocols.length === 0) {
1201
+ throw new Error(
1202
+ "RouterConfig.protocols cannot be empty. Omit the field to use default ['x402'] or specify protocols explicitly."
1203
+ );
1204
+ }
1205
+ if (config.protocols.includes("mpp") && !config.mpp) {
1206
+ throw new Error(
1207
+ 'RouterConfig.protocols includes "mpp" but RouterConfig.mpp is not configured. Add mpp: { secretKey, currency, recipient } to your router config.'
1208
+ );
1209
+ }
1210
+ if (config.protocols.includes("x402") && !config.payeeAddress) {
1211
+ throw new Error(
1212
+ 'RouterConfig.protocols includes "x402" but RouterConfig.payeeAddress is not configured.'
1213
+ );
1214
+ }
1215
+ }
1147
1216
  if (config.plugin?.init) {
1148
1217
  try {
1149
1218
  const result = config.plugin.init({ origin: baseUrl });
@@ -1179,7 +1248,8 @@ function createRouter(config) {
1179
1248
  route(key) {
1180
1249
  const builder = new RouteBuilder(key, registry, deps);
1181
1250
  if (config.prices && key in config.prices) {
1182
- return builder.paid(config.prices[key]);
1251
+ const options = config.protocols ? { protocols: config.protocols } : void 0;
1252
+ return builder.paid(config.prices[key], options);
1183
1253
  }
1184
1254
  return builder;
1185
1255
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentcash/router",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Unified route builder for Next.js App Router APIs with x402, MPP, SIWX, and API key auth",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,17 +21,6 @@
21
21
  "files": [
22
22
  "dist"
23
23
  ],
24
- "scripts": {
25
- "build": "tsup",
26
- "typecheck": "tsc --noEmit",
27
- "lint": "eslint src/",
28
- "lint:fix": "eslint src/ --fix",
29
- "format": "prettier --write 'src/**/*.ts' 'tests/**/*.ts' '*.json' '*.mjs'",
30
- "format:check": "prettier --check 'src/**/*.ts' 'tests/**/*.ts' '*.json' '*.mjs'",
31
- "test": "vitest run",
32
- "test:watch": "vitest",
33
- "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm build && pnpm test"
34
- },
35
24
  "peerDependencies": {
36
25
  "@coinbase/x402": "^2.1.0",
37
26
  "@x402/core": "^2.3.0",
@@ -66,10 +55,20 @@
66
55
  "zod": "^4.0.0",
67
56
  "zod-openapi": "^5.0.0"
68
57
  },
69
- "packageManager": "pnpm@10.28.0",
70
58
  "license": "MIT",
71
59
  "repository": {
72
60
  "type": "git",
73
61
  "url": "git+https://github.com/merit-systems/agentcash-router.git"
62
+ },
63
+ "scripts": {
64
+ "build": "tsup",
65
+ "typecheck": "tsc --noEmit",
66
+ "lint": "eslint src/",
67
+ "lint:fix": "eslint src/ --fix",
68
+ "format": "prettier --write 'src/**/*.ts' 'tests/**/*.ts' '*.json' '*.mjs'",
69
+ "format:check": "prettier --check 'src/**/*.ts' 'tests/**/*.ts' '*.json' '*.mjs'",
70
+ "test": "vitest run",
71
+ "test:watch": "vitest",
72
+ "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm build && pnpm test"
74
73
  }
75
- }
74
+ }