@agentcash/router 0.3.0 → 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
@@ -141,11 +141,28 @@ The fluent builder ensures compile-time safety:
141
141
 
142
142
  ### Pricing Modes
143
143
 
144
- **Static**: `router.route('search').paid('0.02')`
144
+ **Static** - Fixed price for all requests:
145
+ ```typescript
146
+ router.route('search').paid('0.02')
147
+ ```
148
+
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
+ ```
145
156
 
146
- **Dynamic**: `router.route('gen').paid((body) => calculateCost(body), { maxPrice: '5.00' }).body(schema)`
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
+ ```
147
164
 
148
- **Tiered**:
165
+ **Tiered** - Price based on a specific field value:
149
166
  ```typescript
150
167
  router.route('upload').paid({
151
168
  field: 'tier',
@@ -153,7 +170,38 @@ router.route('upload').paid({
153
170
  '10mb': { price: '0.02', label: '10 MB' },
154
171
  '100mb': { price: '0.20', label: '100 MB' },
155
172
  },
156
- }).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));
157
205
  ```
158
206
 
159
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) {
package/dist/index.d.cts CHANGED
@@ -265,7 +265,7 @@ declare class RouteBuilder<TBody = undefined, TQuery = undefined, HasAuth extend
265
265
  private fork;
266
266
  paid(pricing: string, options?: PaidOptions): RouteBuilder<TBody, TQuery, True, False, HasBody>;
267
267
  paid<TBodyIn>(pricing: (body: TBodyIn) => string | Promise<string>, options?: PaidOptions & {
268
- maxPrice: string;
268
+ maxPrice?: string;
269
269
  }): RouteBuilder<TBody, TQuery, True, True, HasBody>;
270
270
  paid(pricing: {
271
271
  field: string;
package/dist/index.d.ts CHANGED
@@ -265,7 +265,7 @@ declare class RouteBuilder<TBody = undefined, TQuery = undefined, HasAuth extend
265
265
  private fork;
266
266
  paid(pricing: string, options?: PaidOptions): RouteBuilder<TBody, TQuery, True, False, HasBody>;
267
267
  paid<TBodyIn>(pricing: (body: TBodyIn) => string | Promise<string>, options?: PaidOptions & {
268
- maxPrice: string;
268
+ maxPrice?: string;
269
269
  }): RouteBuilder<TBody, TQuery, True, True, HasBody>;
270
270
  paid(pricing: {
271
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentcash/router",
3
- "version": "0.3.0",
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
+ }