@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.cjs CHANGED
@@ -76,7 +76,10 @@ __export(index_exports, {
76
76
  MemoryNonceStore: () => MemoryNonceStore,
77
77
  RouteBuilder: () => RouteBuilder,
78
78
  RouteRegistry: () => RouteRegistry,
79
+ SIWX_CHALLENGE_EXPIRY_MS: () => SIWX_CHALLENGE_EXPIRY_MS,
80
+ SIWX_ERROR_MESSAGES: () => SIWX_ERROR_MESSAGES,
79
81
  consolePlugin: () => consolePlugin,
82
+ createRedisNonceStore: () => createRedisNonceStore,
80
83
  createRouter: () => createRouter
81
84
  });
82
85
  module.exports = __toCommonJS(index_exports);
@@ -164,6 +167,10 @@ function consolePlugin() {
164
167
  const ctx = createDefaultContext(meta);
165
168
  return ctx;
166
169
  },
170
+ onAuthVerified(_ctx, auth) {
171
+ const wallet = auth.wallet ? ` wallet=${auth.wallet}` : "";
172
+ console.log(`[router] AUTH ${auth.authMode} ${auth.route}${wallet}`);
173
+ },
167
174
  onPaymentVerified(_ctx, payment) {
168
175
  console.log(`[router] VERIFIED ${payment.protocol} ${payment.payer} ${payment.amount}`);
169
176
  },
@@ -193,6 +200,65 @@ function consolePlugin() {
193
200
  };
194
201
  }
195
202
 
203
+ // src/auth/nonce.ts
204
+ var SIWX_CHALLENGE_EXPIRY_MS = 5 * 60 * 1e3;
205
+ var MemoryNonceStore = class {
206
+ seen = /* @__PURE__ */ new Map();
207
+ async check(nonce) {
208
+ this.evict();
209
+ if (this.seen.has(nonce)) return false;
210
+ this.seen.set(nonce, Date.now() + SIWX_CHALLENGE_EXPIRY_MS);
211
+ return true;
212
+ }
213
+ evict() {
214
+ const now = Date.now();
215
+ for (const [n, exp] of this.seen) {
216
+ if (exp < now) this.seen.delete(n);
217
+ }
218
+ }
219
+ };
220
+ function detectRedisClientType(client) {
221
+ if (!client || typeof client !== "object") {
222
+ throw new Error(
223
+ "createRedisNonceStore requires a Redis client. Supported: @upstash/redis, ioredis. Pass your Redis client instance as the first argument."
224
+ );
225
+ }
226
+ if ("options" in client && "status" in client) {
227
+ return "ioredis";
228
+ }
229
+ const constructor = client.constructor?.name;
230
+ if (constructor === "Redis" && "url" in client) {
231
+ return "upstash";
232
+ }
233
+ if (typeof client.set === "function") {
234
+ return "upstash";
235
+ }
236
+ throw new Error(
237
+ "Unrecognized Redis client. Supported: @upstash/redis, ioredis. If using a different client, implement NonceStore interface directly."
238
+ );
239
+ }
240
+ function createRedisNonceStore(client, opts) {
241
+ const prefix = opts?.prefix ?? "siwx:nonce:";
242
+ const ttlSeconds = Math.ceil((opts?.ttlMs ?? SIWX_CHALLENGE_EXPIRY_MS) / 1e3);
243
+ const clientType = detectRedisClientType(client);
244
+ return {
245
+ async check(nonce) {
246
+ const key = `${prefix}${nonce}`;
247
+ if (clientType === "upstash") {
248
+ const redis = client;
249
+ const result = await redis.set(key, "1", { ex: ttlSeconds, nx: true });
250
+ return result !== null;
251
+ }
252
+ if (clientType === "ioredis") {
253
+ const redis = client;
254
+ const result = await redis.set(key, "1", "EX", ttlSeconds, "NX");
255
+ return result === "OK";
256
+ }
257
+ throw new Error("Unknown Redis client type");
258
+ }
259
+ };
260
+ }
261
+
196
262
  // src/protocols/detect.ts
197
263
  function detectProtocol(request) {
198
264
  if (request.headers.get("PAYMENT-SIGNATURE") || request.headers.get("X-PAYMENT")) {
@@ -368,6 +434,7 @@ async function settleX402Payment(server, payload, requirements) {
368
434
  // src/protocols/mpp.ts
369
435
  var import_mppx = require("mppx");
370
436
  var import_server2 = require("mppx/server");
437
+ var import_tempo = require("mppx/tempo");
371
438
  var import_viem = require("viem");
372
439
  var import_chains = require("viem/chains");
373
440
  function buildGetClient(rpcUrl) {
@@ -391,8 +458,7 @@ async function buildMPPChallenge(routeEntry, request, mppConfig, price) {
391
458
  const standardRequest = toStandardRequest(request);
392
459
  const currency = mppConfig.currency;
393
460
  const recipient = mppConfig.recipient ?? "";
394
- const methodIntent = import_server2.tempo.charge();
395
- const challenge = import_mppx.Challenge.fromIntent(methodIntent, {
461
+ const challenge = import_mppx.Challenge.fromMethod(import_tempo.Methods.charge, {
396
462
  secretKey: mppConfig.secretKey,
397
463
  realm: new URL(standardRequest.url).origin,
398
464
  request: {
@@ -467,21 +533,47 @@ function buildMPPReceipt(reference) {
467
533
  }
468
534
 
469
535
  // src/auth/siwx.ts
536
+ var SIWX_ERROR_MESSAGES = {
537
+ siwx_missing_header: "Missing SIGN-IN-WITH-X header",
538
+ siwx_malformed: "Malformed SIWX payload",
539
+ siwx_expired: "SIWX message expired \u2014 request a new challenge",
540
+ siwx_nonce_used: "Nonce already used \u2014 request a new challenge",
541
+ siwx_invalid_signature: "Invalid signature \u2014 wallet mismatch or corrupted proof"
542
+ };
543
+ function categorizeValidationError(error) {
544
+ if (!error) return "siwx_malformed";
545
+ const err = error.toLowerCase();
546
+ if (err.includes("expired") || err.includes("message too old")) {
547
+ return "siwx_expired";
548
+ }
549
+ if (err.includes("nonce validation failed")) {
550
+ return "siwx_nonce_used";
551
+ }
552
+ return "siwx_malformed";
553
+ }
470
554
  async function verifySIWX(request, _routeEntry, nonceStore) {
471
555
  const { parseSIWxHeader, validateSIWxMessage, verifySIWxSignature } = await import("@x402/extensions/sign-in-with-x");
472
556
  const header = request.headers.get("SIGN-IN-WITH-X");
473
- if (!header) return { valid: false, wallet: null };
474
- const payload = parseSIWxHeader(header);
557
+ if (!header) {
558
+ return { valid: false, wallet: null, code: "siwx_missing_header" };
559
+ }
560
+ let payload;
561
+ try {
562
+ payload = parseSIWxHeader(header);
563
+ } catch {
564
+ return { valid: false, wallet: null, code: "siwx_malformed" };
565
+ }
475
566
  const uri = request.url;
476
567
  const validation = await validateSIWxMessage(payload, uri, {
477
568
  checkNonce: (nonce) => nonceStore.check(nonce)
478
569
  });
479
570
  if (!validation.valid) {
480
- return { valid: false, wallet: null };
571
+ const code = categorizeValidationError(validation.error);
572
+ return { valid: false, wallet: null, code };
481
573
  }
482
574
  const verified = await verifySIWxSignature(payload);
483
575
  if (!verified?.valid) {
484
- return { valid: false, wallet: null };
576
+ return { valid: false, wallet: null, code: "siwx_invalid_signature" };
485
577
  }
486
578
  return { valid: true, wallet: verified.address };
487
579
  }
@@ -555,6 +647,15 @@ function createRequestHandler(routeEntry, handler, deps) {
555
647
  firePluginResponse(deps, pluginCtx, meta, body2.response);
556
648
  return body2.response;
557
649
  }
650
+ if (routeEntry.validateFn) {
651
+ try {
652
+ await routeEntry.validateFn(body2.data);
653
+ } catch (err) {
654
+ const status = err.status ?? 400;
655
+ const message = err instanceof Error ? err.message : "Validation failed";
656
+ return fail(status, message, meta, pluginCtx);
657
+ }
658
+ }
558
659
  const { response, rawResult } = await invoke(
559
660
  request,
560
661
  meta,
@@ -579,13 +680,20 @@ function createRequestHandler(routeEntry, handler, deps) {
579
680
  return fail(401, "Invalid or missing API key", meta, pluginCtx);
580
681
  }
581
682
  account = keyResult.account;
683
+ firePluginHook(deps.plugin, "onAuthVerified", pluginCtx, {
684
+ authMode: "apiKey",
685
+ wallet: null,
686
+ route: routeEntry.key,
687
+ account
688
+ });
582
689
  if (routeEntry.authMode === "apiKey" && !routeEntry.pricing) {
583
690
  return handleAuth(null, account);
584
691
  }
585
692
  }
586
693
  const protocol = detectProtocol(request);
587
694
  let earlyBodyData;
588
- if (!protocol && typeof routeEntry.pricing === "function" && routeEntry.bodySchema) {
695
+ const needsEarlyParse = !protocol && routeEntry.bodySchema && (typeof routeEntry.pricing === "function" || routeEntry.validateFn);
696
+ if (needsEarlyParse) {
589
697
  const requestForPricing = request.clone();
590
698
  const earlyBodyResult = await parseBody(requestForPricing, routeEntry);
591
699
  if (!earlyBodyResult.ok) {
@@ -593,11 +701,35 @@ function createRequestHandler(routeEntry, handler, deps) {
593
701
  return earlyBodyResult.response;
594
702
  }
595
703
  earlyBodyData = earlyBodyResult.data;
704
+ if (routeEntry.validateFn) {
705
+ try {
706
+ await routeEntry.validateFn(earlyBodyData);
707
+ } catch (err) {
708
+ const status = err.status ?? 400;
709
+ const message = err instanceof Error ? err.message : "Validation failed";
710
+ return fail(status, message, meta, pluginCtx);
711
+ }
712
+ }
596
713
  }
597
714
  if (routeEntry.authMode === "siwx") {
715
+ if (routeEntry.validateFn && routeEntry.bodySchema && !request.headers.get("SIGN-IN-WITH-X")) {
716
+ const requestForValidation = request.clone();
717
+ const earlyBodyResult = await parseBody(requestForValidation, routeEntry);
718
+ if (!earlyBodyResult.ok) {
719
+ firePluginResponse(deps, pluginCtx, meta, earlyBodyResult.response);
720
+ return earlyBodyResult.response;
721
+ }
722
+ try {
723
+ await routeEntry.validateFn(earlyBodyResult.data);
724
+ } catch (err) {
725
+ const status = err.status ?? 400;
726
+ const message = err instanceof Error ? err.message : "Validation failed";
727
+ return fail(status, message, meta, pluginCtx);
728
+ }
729
+ }
598
730
  if (!request.headers.get("SIGN-IN-WITH-X")) {
599
731
  const url = new URL(request.url);
600
- const nonce = crypto.randomUUID();
732
+ const nonce = crypto.randomUUID().replace(/-/g, "");
601
733
  const siwxInfo = {
602
734
  domain: url.hostname,
603
735
  uri: request.url,
@@ -606,7 +738,7 @@ function createRequestHandler(routeEntry, handler, deps) {
606
738
  type: "eip191",
607
739
  nonce,
608
740
  issuedAt: (/* @__PURE__ */ new Date()).toISOString(),
609
- expirationTime: new Date(Date.now() + 3e5).toISOString(),
741
+ expirationTime: new Date(Date.now() + SIWX_CHALLENGE_EXPIRY_MS).toISOString(),
610
742
  statement: "Sign in to verify your wallet identity"
611
743
  };
612
744
  let siwxSchema;
@@ -626,6 +758,8 @@ function createRequestHandler(routeEntry, handler, deps) {
626
758
  extensions: {
627
759
  "sign-in-with-x": {
628
760
  info: siwxInfo,
761
+ // supportedChains at top level required by MCP tools for chain detection
762
+ supportedChains: [{ chainId: deps.network, type: "eip191" }],
629
763
  ...siwxSchema ? { schema: siwxSchema } : {}
630
764
  }
631
765
  }
@@ -651,15 +785,21 @@ function createRequestHandler(routeEntry, handler, deps) {
651
785
  }
652
786
  const siwx = await verifySIWX(request, routeEntry, deps.nonceStore);
653
787
  if (!siwx.valid) {
654
- return fail(
655
- 402,
656
- "SIWX verification failed \u2014 signature invalid, message expired, or nonce already used",
657
- meta,
658
- pluginCtx
788
+ const response = import_server3.NextResponse.json(
789
+ { error: siwx.code, message: SIWX_ERROR_MESSAGES[siwx.code] },
790
+ { status: 402 }
659
791
  );
792
+ firePluginResponse(deps, pluginCtx, meta, response);
793
+ return response;
660
794
  }
661
- pluginCtx.setVerifiedWallet(siwx.wallet);
662
- return handleAuth(siwx.wallet, void 0);
795
+ const wallet = siwx.wallet.toLowerCase();
796
+ pluginCtx.setVerifiedWallet(wallet);
797
+ firePluginHook(deps.plugin, "onAuthVerified", pluginCtx, {
798
+ authMode: "siwx",
799
+ wallet,
800
+ route: routeEntry.key
801
+ });
802
+ return handleAuth(wallet, void 0);
663
803
  }
664
804
  if (!protocol || protocol === "siwx") {
665
805
  return await build402(request, routeEntry, deps, meta, pluginCtx, earlyBodyData);
@@ -669,6 +809,15 @@ function createRequestHandler(routeEntry, handler, deps) {
669
809
  firePluginResponse(deps, pluginCtx, meta, body.response);
670
810
  return body.response;
671
811
  }
812
+ if (routeEntry.validateFn) {
813
+ try {
814
+ await routeEntry.validateFn(body.data);
815
+ } catch (err) {
816
+ const status = err.status ?? 400;
817
+ const message = err instanceof Error ? err.message : "Validation failed";
818
+ return fail(status, message, meta, pluginCtx);
819
+ }
820
+ }
672
821
  let price;
673
822
  try {
674
823
  price = await resolvePrice(routeEntry.pricing, body.data);
@@ -695,10 +844,11 @@ function createRequestHandler(routeEntry, handler, deps) {
695
844
  );
696
845
  if (!verify?.valid) return await build402(request, routeEntry, deps, meta, pluginCtx);
697
846
  const { payload: verifyPayload, requirements: verifyRequirements } = verify;
698
- pluginCtx.setVerifiedWallet(verify.payer);
847
+ const wallet = verify.payer.toLowerCase();
848
+ pluginCtx.setVerifiedWallet(wallet);
699
849
  firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
700
850
  protocol: "x402",
701
- payer: verify.payer,
851
+ payer: wallet,
702
852
  amount: price,
703
853
  network: deps.network
704
854
  });
@@ -706,7 +856,7 @@ function createRequestHandler(routeEntry, handler, deps) {
706
856
  request,
707
857
  meta,
708
858
  pluginCtx,
709
- verify.payer,
859
+ wallet,
710
860
  account,
711
861
  body.data
712
862
  );
@@ -757,7 +907,7 @@ function createRequestHandler(routeEntry, handler, deps) {
757
907
  if (!deps.mppConfig) return await build402(request, routeEntry, deps, meta, pluginCtx);
758
908
  const verify = await verifyMPPCredential(request, routeEntry, deps.mppConfig, price);
759
909
  if (!verify?.valid) return await build402(request, routeEntry, deps, meta, pluginCtx);
760
- const wallet = verify.payer;
910
+ const wallet = verify.payer.toLowerCase();
761
911
  pluginCtx.setVerifiedWallet(wallet);
762
912
  firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
763
913
  protocol: "mpp",
@@ -999,6 +1149,8 @@ var RouteBuilder = class {
999
1149
  /** @internal */
1000
1150
  _maxPrice;
1001
1151
  /** @internal */
1152
+ _minPrice;
1153
+ /** @internal */
1002
1154
  _bodySchema;
1003
1155
  /** @internal */
1004
1156
  _querySchema;
@@ -1016,6 +1168,8 @@ var RouteBuilder = class {
1016
1168
  _providerName;
1017
1169
  /** @internal */
1018
1170
  _providerConfig;
1171
+ /** @internal */
1172
+ _validateFn;
1019
1173
  constructor(key, registry, deps) {
1020
1174
  this._key = key;
1021
1175
  this._registry = registry;
@@ -1028,11 +1182,17 @@ var RouteBuilder = class {
1028
1182
  return next;
1029
1183
  }
1030
1184
  paid(pricing, options) {
1185
+ if (this._authMode === "siwx") {
1186
+ throw new Error(
1187
+ `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.`
1188
+ );
1189
+ }
1031
1190
  const next = this.fork();
1032
1191
  next._authMode = "paid";
1033
1192
  next._pricing = pricing;
1034
1193
  if (options?.protocols) next._protocols = options.protocols;
1035
1194
  if (options?.maxPrice) next._maxPrice = options.maxPrice;
1195
+ if (options?.minPrice) next._minPrice = options.minPrice;
1036
1196
  if (typeof pricing === "object" && "tiers" in pricing) {
1037
1197
  for (const [tierKey, tierConfig] of Object.entries(pricing.tiers)) {
1038
1198
  if (!tierKey) {
@@ -1057,6 +1217,11 @@ var RouteBuilder = class {
1057
1217
  return next;
1058
1218
  }
1059
1219
  siwx() {
1220
+ if (this._authMode === "paid") {
1221
+ throw new Error(
1222
+ `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.`
1223
+ );
1224
+ }
1060
1225
  const next = this.fork();
1061
1226
  next._authMode = "siwx";
1062
1227
  next._protocols = [];
@@ -1117,7 +1282,41 @@ var RouteBuilder = class {
1117
1282
  next._method = m;
1118
1283
  return next;
1119
1284
  }
1285
+ // -------------------------------------------------------------------------
1286
+ // Pre-payment validation
1287
+ // -------------------------------------------------------------------------
1288
+ /**
1289
+ * Add pre-payment validation that runs after body parsing but before the 402
1290
+ * challenge is shown. Use this for async business logic like "is this resource
1291
+ * available?" or "has this user hit their rate limit?".
1292
+ *
1293
+ * Requires `.body()` — call `.body()` before `.validate()` for type inference.
1294
+ *
1295
+ * @example
1296
+ * ```typescript
1297
+ * router
1298
+ * .route('domain/register')
1299
+ * .paid(calculatePrice)
1300
+ * .body(RegisterSchema) // .body() first for type inference
1301
+ * .validate(async (body) => {
1302
+ * if (await isDomainTaken(body.domain)) {
1303
+ * throw Object.assign(new Error('Domain taken'), { status: 409 });
1304
+ * }
1305
+ * })
1306
+ * .handler(async ({ body }) => { ... });
1307
+ * ```
1308
+ */
1309
+ validate(fn) {
1310
+ const next = this.fork();
1311
+ next._validateFn = fn;
1312
+ return next;
1313
+ }
1120
1314
  handler(fn) {
1315
+ if (this._validateFn && !this._bodySchema) {
1316
+ throw new Error(
1317
+ `route '${this._key}': .validate() requires .body() \u2014 validation runs on parsed body`
1318
+ );
1319
+ }
1121
1320
  const entry = {
1122
1321
  key: this._key,
1123
1322
  authMode: this._authMode,
@@ -1130,32 +1329,17 @@ var RouteBuilder = class {
1130
1329
  path: this._path,
1131
1330
  method: this._method,
1132
1331
  maxPrice: this._maxPrice,
1332
+ minPrice: this._minPrice,
1133
1333
  apiKeyResolver: this._apiKeyResolver,
1134
1334
  providerName: this._providerName,
1135
- providerConfig: this._providerConfig
1335
+ providerConfig: this._providerConfig,
1336
+ validateFn: this._validateFn
1136
1337
  };
1137
1338
  this._registry.register(entry);
1138
1339
  return createRequestHandler(entry, fn, this._deps);
1139
1340
  }
1140
1341
  };
1141
1342
 
1142
- // src/auth/nonce.ts
1143
- var MemoryNonceStore = class {
1144
- seen = /* @__PURE__ */ new Map();
1145
- async check(nonce) {
1146
- this.evict();
1147
- if (this.seen.has(nonce)) return false;
1148
- this.seen.set(nonce, Date.now() + 5 * 60 * 1e3);
1149
- return true;
1150
- }
1151
- evict() {
1152
- const now = Date.now();
1153
- for (const [n, exp] of this.seen) {
1154
- if (exp < now) this.seen.delete(n);
1155
- }
1156
- }
1157
- };
1158
-
1159
1343
  // src/discovery/well-known.ts
1160
1344
  var import_server4 = require("next/server");
1161
1345
  function createWellKnownHandler(registry, baseUrl, pricesKeys, options = {}) {
@@ -1248,9 +1432,16 @@ function buildOperation(routeKey, entry, tag) {
1248
1432
  const protocols = entry.protocols.length > 0 ? entry.protocols : void 0;
1249
1433
  let price;
1250
1434
  if (typeof entry.pricing === "string") {
1251
- price = parseFloat(entry.pricing);
1435
+ price = entry.pricing;
1436
+ } else if (typeof entry.pricing === "object" && "tiers" in entry.pricing) {
1437
+ const tierPrices = Object.values(entry.pricing.tiers).map((t) => parseFloat(t.price));
1438
+ const min = Math.min(...tierPrices);
1439
+ const max = Math.max(...tierPrices);
1440
+ price = min === max ? String(min) : `${min}-${max}`;
1441
+ } else if (entry.minPrice && entry.maxPrice) {
1442
+ price = `${entry.minPrice}-${entry.maxPrice}`;
1252
1443
  } else if (entry.maxPrice) {
1253
- price = parseFloat(entry.maxPrice);
1444
+ price = entry.maxPrice;
1254
1445
  }
1255
1446
  const operation = {
1256
1447
  operationId: routeKey.replace(/\//g, "_"),
@@ -1388,6 +1579,9 @@ function createRouter(config) {
1388
1579
  MemoryNonceStore,
1389
1580
  RouteBuilder,
1390
1581
  RouteRegistry,
1582
+ SIWX_CHALLENGE_EXPIRY_MS,
1583
+ SIWX_ERROR_MESSAGES,
1391
1584
  consolePlugin,
1585
+ createRedisNonceStore,
1392
1586
  createRouter
1393
1587
  });
package/dist/index.d.cts 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.cjs';
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 };