@agentcash/router 0.4.6 → 0.4.8

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
@@ -1,15 +1,53 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { ZodType } from 'zod';
3
3
  import { PaymentRequirements, PaymentRequired, SettleResponse } from '@x402/core/types';
4
+ export { S as SIWX_ERROR_MESSAGES, a as SiwxErrorCode } from './siwx-BMlja_nt.js';
4
5
 
6
+ /**
7
+ * SIWX challenge expiry in milliseconds.
8
+ * Currently not configurable per-route — this is a known limitation.
9
+ * Future versions may add `siwx: { expiryMs }` to RouterConfig.
10
+ */
11
+ declare const SIWX_CHALLENGE_EXPIRY_MS: number;
5
12
  interface NonceStore {
6
13
  check(nonce: string): Promise<boolean>;
7
14
  }
15
+ /**
16
+ * In-memory nonce store for development and testing.
17
+ * NOT suitable for production serverless environments (Vercel, etc.)
18
+ * where each function invocation gets fresh memory.
19
+ *
20
+ * For production, use `createRedisNonceStore()` with Upstash or ioredis.
21
+ */
8
22
  declare class MemoryNonceStore implements NonceStore {
9
23
  private seen;
10
24
  check(nonce: string): Promise<boolean>;
11
25
  private evict;
12
26
  }
27
+ interface RedisNonceStoreOptions {
28
+ /** Key prefix for nonce storage. Default: 'siwx:nonce:' */
29
+ prefix?: string;
30
+ /** TTL in milliseconds. Default: SIWX_CHALLENGE_EXPIRY_MS (5 minutes) */
31
+ ttlMs?: number;
32
+ }
33
+ /**
34
+ * Create a Redis-backed nonce store for production SIWX replay protection.
35
+ * Auto-detects client type (Upstash or ioredis) and uses appropriate API.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * // Upstash (Vercel)
40
+ * import { Redis } from '@upstash/redis';
41
+ * const redis = new Redis({ url: process.env.UPSTASH_URL, token: process.env.UPSTASH_TOKEN });
42
+ * const nonceStore = createRedisNonceStore(redis);
43
+ *
44
+ * // ioredis
45
+ * import Redis from 'ioredis';
46
+ * const redis = new Redis(process.env.REDIS_URL);
47
+ * const nonceStore = createRedisNonceStore(redis);
48
+ * ```
49
+ */
50
+ declare function createRedisNonceStore(client: unknown, opts?: RedisNonceStoreOptions): NonceStore;
13
51
 
14
52
  interface RequestMeta {
15
53
  requestId: string;
@@ -57,11 +95,23 @@ interface ErrorEvent {
57
95
  message: string;
58
96
  settled: boolean;
59
97
  }
98
+ interface AuthEvent {
99
+ /** Authentication mode that was verified */
100
+ authMode: 'siwx' | 'apiKey';
101
+ /** Verified wallet address (lowercase) */
102
+ wallet: string | null;
103
+ /** Route key */
104
+ route: string;
105
+ /** Account data from API key resolver (for apiKey auth) */
106
+ account?: unknown;
107
+ }
60
108
  interface RouterPlugin {
61
109
  init?(config: {
62
110
  origin?: string;
63
111
  }): void | Promise<void>;
64
112
  onRequest?(meta: RequestMeta): PluginContext;
113
+ /** Fired after successful SIWX or API key verification, before handler */
114
+ onAuthVerified?(ctx: PluginContext, event: AuthEvent): void;
65
115
  onPaymentVerified?(ctx: PluginContext, payment: PaymentEvent): void;
66
116
  onPaymentSettled?(ctx: PluginContext, settlement: SettlementEvent): void;
67
117
  onResponse?(ctx: PluginContext, response: ResponseMeta): void;
@@ -120,6 +170,7 @@ type PricingConfig<TBody = unknown> = string | ((body: TBody) => string | Promis
120
170
  interface PaidOptions {
121
171
  protocols?: ProtocolType[];
122
172
  maxPrice?: string;
173
+ minPrice?: string;
123
174
  }
124
175
  interface HandlerContext<TBody = undefined, TQuery = undefined> {
125
176
  body: TBody;
@@ -168,9 +219,11 @@ interface RouteEntry {
168
219
  path?: string;
169
220
  method: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH';
170
221
  maxPrice?: string;
222
+ minPrice?: string;
171
223
  apiKeyResolver?: (key: string) => unknown | Promise<unknown>;
172
224
  providerName?: string;
173
225
  providerConfig?: ProviderConfig;
226
+ validateFn?: (body: unknown) => void | Promise<void>;
174
227
  }
175
228
  interface RouterConfig {
176
229
  payeeAddress: string;
@@ -257,6 +310,7 @@ declare class RouteBuilder<TBody = undefined, TQuery = undefined, HasAuth extend
257
310
  /** @internal */ _pricing: PricingConfig | undefined;
258
311
  /** @internal */ _protocols: ProtocolType[];
259
312
  /** @internal */ _maxPrice: string | undefined;
313
+ /** @internal */ _minPrice: string | undefined;
260
314
  /** @internal */ _bodySchema: ZodType | undefined;
261
315
  /** @internal */ _querySchema: ZodType | undefined;
262
316
  /** @internal */ _outputSchema: ZodType | undefined;
@@ -266,6 +320,7 @@ declare class RouteBuilder<TBody = undefined, TQuery = undefined, HasAuth extend
266
320
  /** @internal */ _apiKeyResolver: ((key: string) => unknown | Promise<unknown>) | undefined;
267
321
  /** @internal */ _providerName: string | undefined;
268
322
  /** @internal */ _providerConfig: ProviderConfig | undefined;
323
+ /** @internal */ _validateFn: ((body: TBody) => void | Promise<void>) | undefined;
269
324
  constructor(key: string, registry: RouteRegistry, deps: OrchestrateDeps);
270
325
  private fork;
271
326
  paid(pricing: string, options?: PaidOptions): RouteBuilder<TBody, TQuery, True, False, HasBody>;
@@ -290,6 +345,28 @@ declare class RouteBuilder<TBody = undefined, TQuery = undefined, HasAuth extend
290
345
  description(text: string): this;
291
346
  path(p: string): this;
292
347
  method(m: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH'): this;
348
+ /**
349
+ * Add pre-payment validation that runs after body parsing but before the 402
350
+ * challenge is shown. Use this for async business logic like "is this resource
351
+ * available?" or "has this user hit their rate limit?".
352
+ *
353
+ * Requires `.body()` — call `.body()` before `.validate()` for type inference.
354
+ *
355
+ * @example
356
+ * ```typescript
357
+ * router
358
+ * .route('domain/register')
359
+ * .paid(calculatePrice)
360
+ * .body(RegisterSchema) // .body() first for type inference
361
+ * .validate(async (body) => {
362
+ * if (await isDomainTaken(body.domain)) {
363
+ * throw Object.assign(new Error('Domain taken'), { status: 409 });
364
+ * }
365
+ * })
366
+ * .handler(async ({ body }) => { ... });
367
+ * ```
368
+ */
369
+ validate(fn: (body: TBody) => void | Promise<void>): RouteBuilder<TBody, TQuery, HasAuth, NeedsBody, HasBody>;
293
370
  handler(this: RouteBuilder<TBody, TQuery, True, true, false>, fn: never): never;
294
371
  handler(this: RouteBuilder<TBody, TQuery, false, boolean, boolean>, fn: never): never;
295
372
  handler(this: RouteBuilder<TBody, TQuery, True, False, HasBody>, fn: (ctx: HandlerContext<TBody, TQuery>) => Promise<unknown>): (request: NextRequest) => Promise<Response>;
@@ -313,4 +390,4 @@ interface ServiceRouter {
313
390
  }
314
391
  declare function createRouter(config: RouterConfig): ServiceRouter;
315
392
 
316
- export { type AlertEvent, type AlertFn, type AlertLevel, type AuthMode, type ErrorEvent, type HandlerContext, HttpError, MemoryNonceStore, type MonitorEntry, type NonceStore, type OveragePolicy, type PaidOptions, type PaymentEvent, type PluginContext, type PricingConfig, type ProtocolType, type ProviderConfig, type ProviderQuotaEvent, type QuotaInfo, type QuotaLevel, type RequestMeta, type ResponseMeta, RouteBuilder, type RouteEntry, RouteRegistry, type RouterConfig, type RouterPlugin, type ServiceRouter, type SettlementEvent, type TierConfig, type X402Server, consolePlugin, createRouter };
393
+ export { type AlertEvent, type AlertFn, type AlertLevel, type AuthEvent, type AuthMode, type ErrorEvent, type HandlerContext, HttpError, MemoryNonceStore, type MonitorEntry, type NonceStore, type OveragePolicy, type PaidOptions, type PaymentEvent, type PluginContext, type PricingConfig, type ProtocolType, type ProviderConfig, type ProviderQuotaEvent, type QuotaInfo, type QuotaLevel, type RedisNonceStoreOptions, type RequestMeta, type ResponseMeta, RouteBuilder, type RouteEntry, RouteRegistry, type RouterConfig, type RouterPlugin, SIWX_CHALLENGE_EXPIRY_MS, type ServiceRouter, type SettlementEvent, type TierConfig, type X402Server, consolePlugin, createRedisNonceStore, createRouter };
package/dist/index.js CHANGED
@@ -130,6 +130,10 @@ function consolePlugin() {
130
130
  const ctx = createDefaultContext(meta);
131
131
  return ctx;
132
132
  },
133
+ onAuthVerified(_ctx, auth) {
134
+ const wallet = auth.wallet ? ` wallet=${auth.wallet}` : "";
135
+ console.log(`[router] AUTH ${auth.authMode} ${auth.route}${wallet}`);
136
+ },
133
137
  onPaymentVerified(_ctx, payment) {
134
138
  console.log(`[router] VERIFIED ${payment.protocol} ${payment.payer} ${payment.amount}`);
135
139
  },
@@ -159,6 +163,65 @@ function consolePlugin() {
159
163
  };
160
164
  }
161
165
 
166
+ // src/auth/nonce.ts
167
+ var SIWX_CHALLENGE_EXPIRY_MS = 5 * 60 * 1e3;
168
+ var MemoryNonceStore = class {
169
+ seen = /* @__PURE__ */ new Map();
170
+ async check(nonce) {
171
+ this.evict();
172
+ if (this.seen.has(nonce)) return false;
173
+ this.seen.set(nonce, Date.now() + SIWX_CHALLENGE_EXPIRY_MS);
174
+ return true;
175
+ }
176
+ evict() {
177
+ const now = Date.now();
178
+ for (const [n, exp] of this.seen) {
179
+ if (exp < now) this.seen.delete(n);
180
+ }
181
+ }
182
+ };
183
+ function detectRedisClientType(client) {
184
+ if (!client || typeof client !== "object") {
185
+ throw new Error(
186
+ "createRedisNonceStore requires a Redis client. Supported: @upstash/redis, ioredis. Pass your Redis client instance as the first argument."
187
+ );
188
+ }
189
+ if ("options" in client && "status" in client) {
190
+ return "ioredis";
191
+ }
192
+ const constructor = client.constructor?.name;
193
+ if (constructor === "Redis" && "url" in client) {
194
+ return "upstash";
195
+ }
196
+ if (typeof client.set === "function") {
197
+ return "upstash";
198
+ }
199
+ throw new Error(
200
+ "Unrecognized Redis client. Supported: @upstash/redis, ioredis. If using a different client, implement NonceStore interface directly."
201
+ );
202
+ }
203
+ function createRedisNonceStore(client, opts) {
204
+ const prefix = opts?.prefix ?? "siwx:nonce:";
205
+ const ttlSeconds = Math.ceil((opts?.ttlMs ?? SIWX_CHALLENGE_EXPIRY_MS) / 1e3);
206
+ const clientType = detectRedisClientType(client);
207
+ return {
208
+ async check(nonce) {
209
+ const key = `${prefix}${nonce}`;
210
+ if (clientType === "upstash") {
211
+ const redis = client;
212
+ const result = await redis.set(key, "1", { ex: ttlSeconds, nx: true });
213
+ return result !== null;
214
+ }
215
+ if (clientType === "ioredis") {
216
+ const redis = client;
217
+ const result = await redis.set(key, "1", "EX", ttlSeconds, "NX");
218
+ return result === "OK";
219
+ }
220
+ throw new Error("Unknown Redis client type");
221
+ }
222
+ };
223
+ }
224
+
162
225
  // src/protocols/detect.ts
163
226
  function detectProtocol(request) {
164
227
  if (request.headers.get("PAYMENT-SIGNATURE") || request.headers.get("X-PAYMENT")) {
@@ -334,6 +397,7 @@ async function settleX402Payment(server, payload, requirements) {
334
397
  // src/protocols/mpp.ts
335
398
  import { Challenge, Credential, Receipt } from "mppx";
336
399
  import { tempo } from "mppx/server";
400
+ import { Methods } from "mppx/tempo";
337
401
  import { createClient, http } from "viem";
338
402
  import { tempo as tempoChain } from "viem/chains";
339
403
  function buildGetClient(rpcUrl) {
@@ -357,8 +421,7 @@ async function buildMPPChallenge(routeEntry, request, mppConfig, price) {
357
421
  const standardRequest = toStandardRequest(request);
358
422
  const currency = mppConfig.currency;
359
423
  const recipient = mppConfig.recipient ?? "";
360
- const methodIntent = tempo.charge();
361
- const challenge = Challenge.fromIntent(methodIntent, {
424
+ const challenge = Challenge.fromMethod(Methods.charge, {
362
425
  secretKey: mppConfig.secretKey,
363
426
  realm: new URL(standardRequest.url).origin,
364
427
  request: {
@@ -433,21 +496,47 @@ function buildMPPReceipt(reference) {
433
496
  }
434
497
 
435
498
  // src/auth/siwx.ts
499
+ var SIWX_ERROR_MESSAGES = {
500
+ siwx_missing_header: "Missing SIGN-IN-WITH-X header",
501
+ siwx_malformed: "Malformed SIWX payload",
502
+ siwx_expired: "SIWX message expired \u2014 request a new challenge",
503
+ siwx_nonce_used: "Nonce already used \u2014 request a new challenge",
504
+ siwx_invalid_signature: "Invalid signature \u2014 wallet mismatch or corrupted proof"
505
+ };
506
+ function categorizeValidationError(error) {
507
+ if (!error) return "siwx_malformed";
508
+ const err = error.toLowerCase();
509
+ if (err.includes("expired") || err.includes("message too old")) {
510
+ return "siwx_expired";
511
+ }
512
+ if (err.includes("nonce validation failed")) {
513
+ return "siwx_nonce_used";
514
+ }
515
+ return "siwx_malformed";
516
+ }
436
517
  async function verifySIWX(request, _routeEntry, nonceStore) {
437
518
  const { parseSIWxHeader, validateSIWxMessage, verifySIWxSignature } = await import("@x402/extensions/sign-in-with-x");
438
519
  const header = request.headers.get("SIGN-IN-WITH-X");
439
- if (!header) return { valid: false, wallet: null };
440
- const payload = parseSIWxHeader(header);
520
+ if (!header) {
521
+ return { valid: false, wallet: null, code: "siwx_missing_header" };
522
+ }
523
+ let payload;
524
+ try {
525
+ payload = parseSIWxHeader(header);
526
+ } catch {
527
+ return { valid: false, wallet: null, code: "siwx_malformed" };
528
+ }
441
529
  const uri = request.url;
442
530
  const validation = await validateSIWxMessage(payload, uri, {
443
531
  checkNonce: (nonce) => nonceStore.check(nonce)
444
532
  });
445
533
  if (!validation.valid) {
446
- return { valid: false, wallet: null };
534
+ const code = categorizeValidationError(validation.error);
535
+ return { valid: false, wallet: null, code };
447
536
  }
448
537
  const verified = await verifySIWxSignature(payload);
449
538
  if (!verified?.valid) {
450
- return { valid: false, wallet: null };
539
+ return { valid: false, wallet: null, code: "siwx_invalid_signature" };
451
540
  }
452
541
  return { valid: true, wallet: verified.address };
453
542
  }
@@ -521,6 +610,15 @@ function createRequestHandler(routeEntry, handler, deps) {
521
610
  firePluginResponse(deps, pluginCtx, meta, body2.response);
522
611
  return body2.response;
523
612
  }
613
+ if (routeEntry.validateFn) {
614
+ try {
615
+ await routeEntry.validateFn(body2.data);
616
+ } catch (err) {
617
+ const status = err.status ?? 400;
618
+ const message = err instanceof Error ? err.message : "Validation failed";
619
+ return fail(status, message, meta, pluginCtx);
620
+ }
621
+ }
524
622
  const { response, rawResult } = await invoke(
525
623
  request,
526
624
  meta,
@@ -545,13 +643,20 @@ function createRequestHandler(routeEntry, handler, deps) {
545
643
  return fail(401, "Invalid or missing API key", meta, pluginCtx);
546
644
  }
547
645
  account = keyResult.account;
646
+ firePluginHook(deps.plugin, "onAuthVerified", pluginCtx, {
647
+ authMode: "apiKey",
648
+ wallet: null,
649
+ route: routeEntry.key,
650
+ account
651
+ });
548
652
  if (routeEntry.authMode === "apiKey" && !routeEntry.pricing) {
549
653
  return handleAuth(null, account);
550
654
  }
551
655
  }
552
656
  const protocol = detectProtocol(request);
553
657
  let earlyBodyData;
554
- if (!protocol && typeof routeEntry.pricing === "function" && routeEntry.bodySchema) {
658
+ const needsEarlyParse = !protocol && routeEntry.bodySchema && (typeof routeEntry.pricing === "function" || routeEntry.validateFn);
659
+ if (needsEarlyParse) {
555
660
  const requestForPricing = request.clone();
556
661
  const earlyBodyResult = await parseBody(requestForPricing, routeEntry);
557
662
  if (!earlyBodyResult.ok) {
@@ -559,11 +664,35 @@ function createRequestHandler(routeEntry, handler, deps) {
559
664
  return earlyBodyResult.response;
560
665
  }
561
666
  earlyBodyData = earlyBodyResult.data;
667
+ if (routeEntry.validateFn) {
668
+ try {
669
+ await routeEntry.validateFn(earlyBodyData);
670
+ } catch (err) {
671
+ const status = err.status ?? 400;
672
+ const message = err instanceof Error ? err.message : "Validation failed";
673
+ return fail(status, message, meta, pluginCtx);
674
+ }
675
+ }
562
676
  }
563
677
  if (routeEntry.authMode === "siwx") {
678
+ if (routeEntry.validateFn && routeEntry.bodySchema && !request.headers.get("SIGN-IN-WITH-X")) {
679
+ const requestForValidation = request.clone();
680
+ const earlyBodyResult = await parseBody(requestForValidation, routeEntry);
681
+ if (!earlyBodyResult.ok) {
682
+ firePluginResponse(deps, pluginCtx, meta, earlyBodyResult.response);
683
+ return earlyBodyResult.response;
684
+ }
685
+ try {
686
+ await routeEntry.validateFn(earlyBodyResult.data);
687
+ } catch (err) {
688
+ const status = err.status ?? 400;
689
+ const message = err instanceof Error ? err.message : "Validation failed";
690
+ return fail(status, message, meta, pluginCtx);
691
+ }
692
+ }
564
693
  if (!request.headers.get("SIGN-IN-WITH-X")) {
565
694
  const url = new URL(request.url);
566
- const nonce = crypto.randomUUID();
695
+ const nonce = crypto.randomUUID().replace(/-/g, "");
567
696
  const siwxInfo = {
568
697
  domain: url.hostname,
569
698
  uri: request.url,
@@ -572,7 +701,7 @@ function createRequestHandler(routeEntry, handler, deps) {
572
701
  type: "eip191",
573
702
  nonce,
574
703
  issuedAt: (/* @__PURE__ */ new Date()).toISOString(),
575
- expirationTime: new Date(Date.now() + 3e5).toISOString(),
704
+ expirationTime: new Date(Date.now() + SIWX_CHALLENGE_EXPIRY_MS).toISOString(),
576
705
  statement: "Sign in to verify your wallet identity"
577
706
  };
578
707
  let siwxSchema;
@@ -592,6 +721,8 @@ function createRequestHandler(routeEntry, handler, deps) {
592
721
  extensions: {
593
722
  "sign-in-with-x": {
594
723
  info: siwxInfo,
724
+ // supportedChains at top level required by MCP tools for chain detection
725
+ supportedChains: [{ chainId: deps.network, type: "eip191" }],
595
726
  ...siwxSchema ? { schema: siwxSchema } : {}
596
727
  }
597
728
  }
@@ -617,15 +748,21 @@ function createRequestHandler(routeEntry, handler, deps) {
617
748
  }
618
749
  const siwx = await verifySIWX(request, routeEntry, deps.nonceStore);
619
750
  if (!siwx.valid) {
620
- return fail(
621
- 402,
622
- "SIWX verification failed \u2014 signature invalid, message expired, or nonce already used",
623
- meta,
624
- pluginCtx
751
+ const response = NextResponse2.json(
752
+ { error: siwx.code, message: SIWX_ERROR_MESSAGES[siwx.code] },
753
+ { status: 402 }
625
754
  );
755
+ firePluginResponse(deps, pluginCtx, meta, response);
756
+ return response;
626
757
  }
627
- pluginCtx.setVerifiedWallet(siwx.wallet);
628
- return handleAuth(siwx.wallet, void 0);
758
+ const wallet = siwx.wallet.toLowerCase();
759
+ pluginCtx.setVerifiedWallet(wallet);
760
+ firePluginHook(deps.plugin, "onAuthVerified", pluginCtx, {
761
+ authMode: "siwx",
762
+ wallet,
763
+ route: routeEntry.key
764
+ });
765
+ return handleAuth(wallet, void 0);
629
766
  }
630
767
  if (!protocol || protocol === "siwx") {
631
768
  return await build402(request, routeEntry, deps, meta, pluginCtx, earlyBodyData);
@@ -635,6 +772,15 @@ function createRequestHandler(routeEntry, handler, deps) {
635
772
  firePluginResponse(deps, pluginCtx, meta, body.response);
636
773
  return body.response;
637
774
  }
775
+ if (routeEntry.validateFn) {
776
+ try {
777
+ await routeEntry.validateFn(body.data);
778
+ } catch (err) {
779
+ const status = err.status ?? 400;
780
+ const message = err instanceof Error ? err.message : "Validation failed";
781
+ return fail(status, message, meta, pluginCtx);
782
+ }
783
+ }
638
784
  let price;
639
785
  try {
640
786
  price = await resolvePrice(routeEntry.pricing, body.data);
@@ -661,10 +807,11 @@ function createRequestHandler(routeEntry, handler, deps) {
661
807
  );
662
808
  if (!verify?.valid) return await build402(request, routeEntry, deps, meta, pluginCtx);
663
809
  const { payload: verifyPayload, requirements: verifyRequirements } = verify;
664
- pluginCtx.setVerifiedWallet(verify.payer);
810
+ const wallet = verify.payer.toLowerCase();
811
+ pluginCtx.setVerifiedWallet(wallet);
665
812
  firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
666
813
  protocol: "x402",
667
- payer: verify.payer,
814
+ payer: wallet,
668
815
  amount: price,
669
816
  network: deps.network
670
817
  });
@@ -672,7 +819,7 @@ function createRequestHandler(routeEntry, handler, deps) {
672
819
  request,
673
820
  meta,
674
821
  pluginCtx,
675
- verify.payer,
822
+ wallet,
676
823
  account,
677
824
  body.data
678
825
  );
@@ -723,7 +870,7 @@ function createRequestHandler(routeEntry, handler, deps) {
723
870
  if (!deps.mppConfig) return await build402(request, routeEntry, deps, meta, pluginCtx);
724
871
  const verify = await verifyMPPCredential(request, routeEntry, deps.mppConfig, price);
725
872
  if (!verify?.valid) return await build402(request, routeEntry, deps, meta, pluginCtx);
726
- const wallet = verify.payer;
873
+ const wallet = verify.payer.toLowerCase();
727
874
  pluginCtx.setVerifiedWallet(wallet);
728
875
  firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
729
876
  protocol: "mpp",
@@ -965,6 +1112,8 @@ var RouteBuilder = class {
965
1112
  /** @internal */
966
1113
  _maxPrice;
967
1114
  /** @internal */
1115
+ _minPrice;
1116
+ /** @internal */
968
1117
  _bodySchema;
969
1118
  /** @internal */
970
1119
  _querySchema;
@@ -982,6 +1131,8 @@ var RouteBuilder = class {
982
1131
  _providerName;
983
1132
  /** @internal */
984
1133
  _providerConfig;
1134
+ /** @internal */
1135
+ _validateFn;
985
1136
  constructor(key, registry, deps) {
986
1137
  this._key = key;
987
1138
  this._registry = registry;
@@ -994,11 +1145,17 @@ var RouteBuilder = class {
994
1145
  return next;
995
1146
  }
996
1147
  paid(pricing, options) {
1148
+ if (this._authMode === "siwx") {
1149
+ throw new Error(
1150
+ `route '${this._key}': Cannot combine .paid() and .siwx() on the same route. Paid routes get wallet identity from the payment proof. Use separate routes if you need both payment and SIWX auth.`
1151
+ );
1152
+ }
997
1153
  const next = this.fork();
998
1154
  next._authMode = "paid";
999
1155
  next._pricing = pricing;
1000
1156
  if (options?.protocols) next._protocols = options.protocols;
1001
1157
  if (options?.maxPrice) next._maxPrice = options.maxPrice;
1158
+ if (options?.minPrice) next._minPrice = options.minPrice;
1002
1159
  if (typeof pricing === "object" && "tiers" in pricing) {
1003
1160
  for (const [tierKey, tierConfig] of Object.entries(pricing.tiers)) {
1004
1161
  if (!tierKey) {
@@ -1023,6 +1180,11 @@ var RouteBuilder = class {
1023
1180
  return next;
1024
1181
  }
1025
1182
  siwx() {
1183
+ if (this._authMode === "paid") {
1184
+ throw new Error(
1185
+ `route '${this._key}': Cannot combine .paid() and .siwx() on the same route. Paid routes get wallet identity from the payment proof. Use separate routes if you need both payment and SIWX auth.`
1186
+ );
1187
+ }
1026
1188
  const next = this.fork();
1027
1189
  next._authMode = "siwx";
1028
1190
  next._protocols = [];
@@ -1083,7 +1245,41 @@ var RouteBuilder = class {
1083
1245
  next._method = m;
1084
1246
  return next;
1085
1247
  }
1248
+ // -------------------------------------------------------------------------
1249
+ // Pre-payment validation
1250
+ // -------------------------------------------------------------------------
1251
+ /**
1252
+ * Add pre-payment validation that runs after body parsing but before the 402
1253
+ * challenge is shown. Use this for async business logic like "is this resource
1254
+ * available?" or "has this user hit their rate limit?".
1255
+ *
1256
+ * Requires `.body()` — call `.body()` before `.validate()` for type inference.
1257
+ *
1258
+ * @example
1259
+ * ```typescript
1260
+ * router
1261
+ * .route('domain/register')
1262
+ * .paid(calculatePrice)
1263
+ * .body(RegisterSchema) // .body() first for type inference
1264
+ * .validate(async (body) => {
1265
+ * if (await isDomainTaken(body.domain)) {
1266
+ * throw Object.assign(new Error('Domain taken'), { status: 409 });
1267
+ * }
1268
+ * })
1269
+ * .handler(async ({ body }) => { ... });
1270
+ * ```
1271
+ */
1272
+ validate(fn) {
1273
+ const next = this.fork();
1274
+ next._validateFn = fn;
1275
+ return next;
1276
+ }
1086
1277
  handler(fn) {
1278
+ if (this._validateFn && !this._bodySchema) {
1279
+ throw new Error(
1280
+ `route '${this._key}': .validate() requires .body() \u2014 validation runs on parsed body`
1281
+ );
1282
+ }
1087
1283
  const entry = {
1088
1284
  key: this._key,
1089
1285
  authMode: this._authMode,
@@ -1096,32 +1292,17 @@ var RouteBuilder = class {
1096
1292
  path: this._path,
1097
1293
  method: this._method,
1098
1294
  maxPrice: this._maxPrice,
1295
+ minPrice: this._minPrice,
1099
1296
  apiKeyResolver: this._apiKeyResolver,
1100
1297
  providerName: this._providerName,
1101
- providerConfig: this._providerConfig
1298
+ providerConfig: this._providerConfig,
1299
+ validateFn: this._validateFn
1102
1300
  };
1103
1301
  this._registry.register(entry);
1104
1302
  return createRequestHandler(entry, fn, this._deps);
1105
1303
  }
1106
1304
  };
1107
1305
 
1108
- // src/auth/nonce.ts
1109
- var MemoryNonceStore = class {
1110
- seen = /* @__PURE__ */ new Map();
1111
- async check(nonce) {
1112
- this.evict();
1113
- if (this.seen.has(nonce)) return false;
1114
- this.seen.set(nonce, Date.now() + 5 * 60 * 1e3);
1115
- return true;
1116
- }
1117
- evict() {
1118
- const now = Date.now();
1119
- for (const [n, exp] of this.seen) {
1120
- if (exp < now) this.seen.delete(n);
1121
- }
1122
- }
1123
- };
1124
-
1125
1306
  // src/discovery/well-known.ts
1126
1307
  import { NextResponse as NextResponse3 } from "next/server";
1127
1308
  function createWellKnownHandler(registry, baseUrl, pricesKeys, options = {}) {
@@ -1214,9 +1395,16 @@ function buildOperation(routeKey, entry, tag) {
1214
1395
  const protocols = entry.protocols.length > 0 ? entry.protocols : void 0;
1215
1396
  let price;
1216
1397
  if (typeof entry.pricing === "string") {
1217
- price = parseFloat(entry.pricing);
1398
+ price = entry.pricing;
1399
+ } else if (typeof entry.pricing === "object" && "tiers" in entry.pricing) {
1400
+ const tierPrices = Object.values(entry.pricing.tiers).map((t) => parseFloat(t.price));
1401
+ const min = Math.min(...tierPrices);
1402
+ const max = Math.max(...tierPrices);
1403
+ price = min === max ? String(min) : `${min}-${max}`;
1404
+ } else if (entry.minPrice && entry.maxPrice) {
1405
+ price = `${entry.minPrice}-${entry.maxPrice}`;
1218
1406
  } else if (entry.maxPrice) {
1219
- price = parseFloat(entry.maxPrice);
1407
+ price = entry.maxPrice;
1220
1408
  }
1221
1409
  const operation = {
1222
1410
  operationId: routeKey.replace(/\//g, "_"),
@@ -1353,6 +1541,9 @@ export {
1353
1541
  MemoryNonceStore,
1354
1542
  RouteBuilder,
1355
1543
  RouteRegistry,
1544
+ SIWX_CHALLENGE_EXPIRY_MS,
1545
+ SIWX_ERROR_MESSAGES,
1356
1546
  consolePlugin,
1547
+ createRedisNonceStore,
1357
1548
  createRouter
1358
1549
  };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * SIWX verification error codes.
3
+ * Enables clients to auto-retry transient failures (e.g., expired challenge).
4
+ */
5
+ type SiwxErrorCode = 'siwx_missing_header' | 'siwx_malformed' | 'siwx_expired' | 'siwx_nonce_used' | 'siwx_invalid_signature';
6
+ /** Human-readable error messages for each SIWX error code. */
7
+ declare const SIWX_ERROR_MESSAGES: Record<SiwxErrorCode, string>;
8
+
9
+ export { SIWX_ERROR_MESSAGES as S, type SiwxErrorCode as a };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * SIWX verification error codes.
3
+ * Enables clients to auto-retry transient failures (e.g., expired challenge).
4
+ */
5
+ type SiwxErrorCode = 'siwx_missing_header' | 'siwx_malformed' | 'siwx_expired' | 'siwx_nonce_used' | 'siwx_invalid_signature';
6
+ /** Human-readable error messages for each SIWX error code. */
7
+ declare const SIWX_ERROR_MESSAGES: Record<SiwxErrorCode, string>;
8
+
9
+ export { SIWX_ERROR_MESSAGES as S, type SiwxErrorCode as a };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentcash/router",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
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": {
@@ -28,7 +28,7 @@
28
28
  "@x402/core": "^2.3.0",
29
29
  "@x402/evm": "^2.3.0",
30
30
  "@x402/extensions": "^2.3.0",
31
- "mppx": "^0.2.0",
31
+ "mppx": "^0.2.2",
32
32
  "next": ">=15.0.0",
33
33
  "zod": "^4.0.0",
34
34
  "zod-openapi": "^5.0.0"
@@ -47,8 +47,8 @@
47
47
  "@x402/evm": "^2.3.0",
48
48
  "@x402/extensions": "^2.3.0",
49
49
  "eslint": "^10.0.0",
50
- "mppx": "^0.2.0",
51
50
  "next": "^15.0.0",
51
+ "mppx": "^0.2.2",
52
52
  "prettier": "^3.8.1",
53
53
  "react": "^19.0.0",
54
54
  "tsup": "^8.0.0",