@agentcash/router 0.4.5 → 0.4.7

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")) {
@@ -354,6 +420,12 @@ async function verifyX402Payment(server, request, routeEntry, price, payeeAddres
354
420
  }
355
421
  async function settleX402Payment(server, payload, requirements) {
356
422
  const { encodePaymentResponseHeader } = await import("@x402/core/http");
423
+ const payloadKeys = typeof payload === "object" && payload !== null ? Object.keys(payload).sort().join(",") : "n/a";
424
+ const reqKeys = typeof requirements === "object" && requirements !== null ? Object.keys(requirements).sort().join(",") : "n/a";
425
+ console.info("x402 settle input", {
426
+ payloadKeys,
427
+ requirementsKeys: reqKeys
428
+ });
357
429
  const result = await server.settlePayment(payload, requirements);
358
430
  const encoded = encodePaymentResponseHeader(result);
359
431
  return { encoded, result };
@@ -461,21 +533,47 @@ function buildMPPReceipt(reference) {
461
533
  }
462
534
 
463
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
+ }
464
554
  async function verifySIWX(request, _routeEntry, nonceStore) {
465
555
  const { parseSIWxHeader, validateSIWxMessage, verifySIWxSignature } = await import("@x402/extensions/sign-in-with-x");
466
556
  const header = request.headers.get("SIGN-IN-WITH-X");
467
- if (!header) return { valid: false, wallet: null };
468
- 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
+ }
469
566
  const uri = request.url;
470
567
  const validation = await validateSIWxMessage(payload, uri, {
471
568
  checkNonce: (nonce) => nonceStore.check(nonce)
472
569
  });
473
570
  if (!validation.valid) {
474
- return { valid: false, wallet: null };
571
+ const code = categorizeValidationError(validation.error);
572
+ return { valid: false, wallet: null, code };
475
573
  }
476
574
  const verified = await verifySIWxSignature(payload);
477
575
  if (!verified?.valid) {
478
- return { valid: false, wallet: null };
576
+ return { valid: false, wallet: null, code: "siwx_invalid_signature" };
479
577
  }
480
578
  return { valid: true, wallet: verified.address };
481
579
  }
@@ -549,6 +647,15 @@ function createRequestHandler(routeEntry, handler, deps) {
549
647
  firePluginResponse(deps, pluginCtx, meta, body2.response);
550
648
  return body2.response;
551
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
+ }
552
659
  const { response, rawResult } = await invoke(
553
660
  request,
554
661
  meta,
@@ -573,13 +680,20 @@ function createRequestHandler(routeEntry, handler, deps) {
573
680
  return fail(401, "Invalid or missing API key", meta, pluginCtx);
574
681
  }
575
682
  account = keyResult.account;
683
+ firePluginHook(deps.plugin, "onAuthVerified", pluginCtx, {
684
+ authMode: "apiKey",
685
+ wallet: null,
686
+ route: routeEntry.key,
687
+ account
688
+ });
576
689
  if (routeEntry.authMode === "apiKey" && !routeEntry.pricing) {
577
690
  return handleAuth(null, account);
578
691
  }
579
692
  }
580
693
  const protocol = detectProtocol(request);
581
694
  let earlyBodyData;
582
- if (!protocol && typeof routeEntry.pricing === "function" && routeEntry.bodySchema) {
695
+ const needsEarlyParse = !protocol && routeEntry.bodySchema && (typeof routeEntry.pricing === "function" || routeEntry.validateFn);
696
+ if (needsEarlyParse) {
583
697
  const requestForPricing = request.clone();
584
698
  const earlyBodyResult = await parseBody(requestForPricing, routeEntry);
585
699
  if (!earlyBodyResult.ok) {
@@ -587,11 +701,35 @@ function createRequestHandler(routeEntry, handler, deps) {
587
701
  return earlyBodyResult.response;
588
702
  }
589
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
+ }
590
713
  }
591
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
+ }
592
730
  if (!request.headers.get("SIGN-IN-WITH-X")) {
593
731
  const url = new URL(request.url);
594
- const nonce = crypto.randomUUID();
732
+ const nonce = crypto.randomUUID().replace(/-/g, "");
595
733
  const siwxInfo = {
596
734
  domain: url.hostname,
597
735
  uri: request.url,
@@ -600,7 +738,7 @@ function createRequestHandler(routeEntry, handler, deps) {
600
738
  type: "eip191",
601
739
  nonce,
602
740
  issuedAt: (/* @__PURE__ */ new Date()).toISOString(),
603
- expirationTime: new Date(Date.now() + 3e5).toISOString(),
741
+ expirationTime: new Date(Date.now() + SIWX_CHALLENGE_EXPIRY_MS).toISOString(),
604
742
  statement: "Sign in to verify your wallet identity"
605
743
  };
606
744
  let siwxSchema;
@@ -620,6 +758,8 @@ function createRequestHandler(routeEntry, handler, deps) {
620
758
  extensions: {
621
759
  "sign-in-with-x": {
622
760
  info: siwxInfo,
761
+ // supportedChains at top level required by MCP tools for chain detection
762
+ supportedChains: [{ chainId: deps.network, type: "eip191" }],
623
763
  ...siwxSchema ? { schema: siwxSchema } : {}
624
764
  }
625
765
  }
@@ -645,15 +785,21 @@ function createRequestHandler(routeEntry, handler, deps) {
645
785
  }
646
786
  const siwx = await verifySIWX(request, routeEntry, deps.nonceStore);
647
787
  if (!siwx.valid) {
648
- return fail(
649
- 402,
650
- "SIWX verification failed \u2014 signature invalid, message expired, or nonce already used",
651
- meta,
652
- pluginCtx
788
+ const response = import_server3.NextResponse.json(
789
+ { error: siwx.code, message: SIWX_ERROR_MESSAGES[siwx.code] },
790
+ { status: 402 }
653
791
  );
792
+ firePluginResponse(deps, pluginCtx, meta, response);
793
+ return response;
654
794
  }
655
- pluginCtx.setVerifiedWallet(siwx.wallet);
656
- 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);
657
803
  }
658
804
  if (!protocol || protocol === "siwx") {
659
805
  return await build402(request, routeEntry, deps, meta, pluginCtx, earlyBodyData);
@@ -663,6 +809,15 @@ function createRequestHandler(routeEntry, handler, deps) {
663
809
  firePluginResponse(deps, pluginCtx, meta, body.response);
664
810
  return body.response;
665
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
+ }
666
821
  let price;
667
822
  try {
668
823
  price = await resolvePrice(routeEntry.pricing, body.data);
@@ -688,10 +843,12 @@ function createRequestHandler(routeEntry, handler, deps) {
688
843
  deps.network
689
844
  );
690
845
  if (!verify?.valid) return await build402(request, routeEntry, deps, meta, pluginCtx);
691
- pluginCtx.setVerifiedWallet(verify.payer);
846
+ const { payload: verifyPayload, requirements: verifyRequirements } = verify;
847
+ const wallet = verify.payer.toLowerCase();
848
+ pluginCtx.setVerifiedWallet(wallet);
692
849
  firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
693
850
  protocol: "x402",
694
- payer: verify.payer,
851
+ payer: wallet,
695
852
  amount: price,
696
853
  network: deps.network
697
854
  });
@@ -699,16 +856,25 @@ function createRequestHandler(routeEntry, handler, deps) {
699
856
  request,
700
857
  meta,
701
858
  pluginCtx,
702
- verify.payer,
859
+ wallet,
703
860
  account,
704
861
  body.data
705
862
  );
706
863
  if (response.status < 400) {
707
864
  try {
865
+ const payloadFingerprint = typeof verifyPayload === "object" && verifyPayload !== null ? {
866
+ keys: Object.keys(verifyPayload).sort().join(","),
867
+ payloadType: typeof verifyPayload
868
+ } : { payloadType: typeof verifyPayload };
869
+ console.info("Settlement attempt", {
870
+ route: routeEntry.key,
871
+ network: deps.network,
872
+ ...payloadFingerprint
873
+ });
708
874
  const settle = await settleX402Payment(
709
875
  deps.x402Server,
710
- verify.payload,
711
- verify.requirements
876
+ verifyPayload,
877
+ verifyRequirements
712
878
  );
713
879
  response.headers.set("PAYMENT-RESPONSE", settle.encoded);
714
880
  firePluginHook(deps.plugin, "onPaymentSettled", pluginCtx, {
@@ -718,6 +884,14 @@ function createRequestHandler(routeEntry, handler, deps) {
718
884
  network: deps.network
719
885
  });
720
886
  } catch (err) {
887
+ const errObj = err;
888
+ console.error("Settlement failed", {
889
+ message: err instanceof Error ? err.message : String(err),
890
+ route: routeEntry.key,
891
+ network: deps.network,
892
+ facilitatorStatus: errObj.response?.status,
893
+ facilitatorBody: errObj.response?.data ?? errObj.response?.body
894
+ });
721
895
  firePluginHook(deps.plugin, "onAlert", pluginCtx, {
722
896
  level: "critical",
723
897
  message: `Settlement failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -733,7 +907,7 @@ function createRequestHandler(routeEntry, handler, deps) {
733
907
  if (!deps.mppConfig) return await build402(request, routeEntry, deps, meta, pluginCtx);
734
908
  const verify = await verifyMPPCredential(request, routeEntry, deps.mppConfig, price);
735
909
  if (!verify?.valid) return await build402(request, routeEntry, deps, meta, pluginCtx);
736
- const wallet = verify.payer;
910
+ const wallet = verify.payer.toLowerCase();
737
911
  pluginCtx.setVerifiedWallet(wallet);
738
912
  firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
739
913
  protocol: "mpp",
@@ -992,6 +1166,8 @@ var RouteBuilder = class {
992
1166
  _providerName;
993
1167
  /** @internal */
994
1168
  _providerConfig;
1169
+ /** @internal */
1170
+ _validateFn;
995
1171
  constructor(key, registry, deps) {
996
1172
  this._key = key;
997
1173
  this._registry = registry;
@@ -1004,6 +1180,11 @@ var RouteBuilder = class {
1004
1180
  return next;
1005
1181
  }
1006
1182
  paid(pricing, options) {
1183
+ if (this._authMode === "siwx") {
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
+ }
1007
1188
  const next = this.fork();
1008
1189
  next._authMode = "paid";
1009
1190
  next._pricing = pricing;
@@ -1033,6 +1214,11 @@ var RouteBuilder = class {
1033
1214
  return next;
1034
1215
  }
1035
1216
  siwx() {
1217
+ if (this._authMode === "paid") {
1218
+ throw new Error(
1219
+ `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.`
1220
+ );
1221
+ }
1036
1222
  const next = this.fork();
1037
1223
  next._authMode = "siwx";
1038
1224
  next._protocols = [];
@@ -1093,7 +1279,41 @@ var RouteBuilder = class {
1093
1279
  next._method = m;
1094
1280
  return next;
1095
1281
  }
1282
+ // -------------------------------------------------------------------------
1283
+ // Pre-payment validation
1284
+ // -------------------------------------------------------------------------
1285
+ /**
1286
+ * Add pre-payment validation that runs after body parsing but before the 402
1287
+ * challenge is shown. Use this for async business logic like "is this resource
1288
+ * available?" or "has this user hit their rate limit?".
1289
+ *
1290
+ * Requires `.body()` — call `.body()` before `.validate()` for type inference.
1291
+ *
1292
+ * @example
1293
+ * ```typescript
1294
+ * router
1295
+ * .route('domain/register')
1296
+ * .paid(calculatePrice)
1297
+ * .body(RegisterSchema) // .body() first for type inference
1298
+ * .validate(async (body) => {
1299
+ * if (await isDomainTaken(body.domain)) {
1300
+ * throw Object.assign(new Error('Domain taken'), { status: 409 });
1301
+ * }
1302
+ * })
1303
+ * .handler(async ({ body }) => { ... });
1304
+ * ```
1305
+ */
1306
+ validate(fn) {
1307
+ const next = this.fork();
1308
+ next._validateFn = fn;
1309
+ return next;
1310
+ }
1096
1311
  handler(fn) {
1312
+ if (this._validateFn && !this._bodySchema) {
1313
+ throw new Error(
1314
+ `route '${this._key}': .validate() requires .body() \u2014 validation runs on parsed body`
1315
+ );
1316
+ }
1097
1317
  const entry = {
1098
1318
  key: this._key,
1099
1319
  authMode: this._authMode,
@@ -1108,30 +1328,14 @@ var RouteBuilder = class {
1108
1328
  maxPrice: this._maxPrice,
1109
1329
  apiKeyResolver: this._apiKeyResolver,
1110
1330
  providerName: this._providerName,
1111
- providerConfig: this._providerConfig
1331
+ providerConfig: this._providerConfig,
1332
+ validateFn: this._validateFn
1112
1333
  };
1113
1334
  this._registry.register(entry);
1114
1335
  return createRequestHandler(entry, fn, this._deps);
1115
1336
  }
1116
1337
  };
1117
1338
 
1118
- // src/auth/nonce.ts
1119
- var MemoryNonceStore = class {
1120
- seen = /* @__PURE__ */ new Map();
1121
- async check(nonce) {
1122
- this.evict();
1123
- if (this.seen.has(nonce)) return false;
1124
- this.seen.set(nonce, Date.now() + 5 * 60 * 1e3);
1125
- return true;
1126
- }
1127
- evict() {
1128
- const now = Date.now();
1129
- for (const [n, exp] of this.seen) {
1130
- if (exp < now) this.seen.delete(n);
1131
- }
1132
- }
1133
- };
1134
-
1135
1339
  // src/discovery/well-known.ts
1136
1340
  var import_server4 = require("next/server");
1137
1341
  function createWellKnownHandler(registry, baseUrl, pricesKeys, options = {}) {
@@ -1364,6 +1568,9 @@ function createRouter(config) {
1364
1568
  MemoryNonceStore,
1365
1569
  RouteBuilder,
1366
1570
  RouteRegistry,
1571
+ SIWX_CHALLENGE_EXPIRY_MS,
1572
+ SIWX_ERROR_MESSAGES,
1367
1573
  consolePlugin,
1574
+ createRedisNonceStore,
1368
1575
  createRouter
1369
1576
  });
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;
@@ -171,6 +221,7 @@ interface RouteEntry {
171
221
  apiKeyResolver?: (key: string) => unknown | Promise<unknown>;
172
222
  providerName?: string;
173
223
  providerConfig?: ProviderConfig;
224
+ validateFn?: (body: unknown) => void | Promise<void>;
174
225
  }
175
226
  interface RouterConfig {
176
227
  payeeAddress: string;
@@ -266,6 +317,7 @@ declare class RouteBuilder<TBody = undefined, TQuery = undefined, HasAuth extend
266
317
  /** @internal */ _apiKeyResolver: ((key: string) => unknown | Promise<unknown>) | undefined;
267
318
  /** @internal */ _providerName: string | undefined;
268
319
  /** @internal */ _providerConfig: ProviderConfig | undefined;
320
+ /** @internal */ _validateFn: ((body: TBody) => void | Promise<void>) | undefined;
269
321
  constructor(key: string, registry: RouteRegistry, deps: OrchestrateDeps);
270
322
  private fork;
271
323
  paid(pricing: string, options?: PaidOptions): RouteBuilder<TBody, TQuery, True, False, HasBody>;
@@ -290,6 +342,28 @@ declare class RouteBuilder<TBody = undefined, TQuery = undefined, HasAuth extend
290
342
  description(text: string): this;
291
343
  path(p: string): this;
292
344
  method(m: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH'): this;
345
+ /**
346
+ * Add pre-payment validation that runs after body parsing but before the 402
347
+ * challenge is shown. Use this for async business logic like "is this resource
348
+ * available?" or "has this user hit their rate limit?".
349
+ *
350
+ * Requires `.body()` — call `.body()` before `.validate()` for type inference.
351
+ *
352
+ * @example
353
+ * ```typescript
354
+ * router
355
+ * .route('domain/register')
356
+ * .paid(calculatePrice)
357
+ * .body(RegisterSchema) // .body() first for type inference
358
+ * .validate(async (body) => {
359
+ * if (await isDomainTaken(body.domain)) {
360
+ * throw Object.assign(new Error('Domain taken'), { status: 409 });
361
+ * }
362
+ * })
363
+ * .handler(async ({ body }) => { ... });
364
+ * ```
365
+ */
366
+ validate(fn: (body: TBody) => void | Promise<void>): RouteBuilder<TBody, TQuery, HasAuth, NeedsBody, HasBody>;
293
367
  handler(this: RouteBuilder<TBody, TQuery, True, true, false>, fn: never): never;
294
368
  handler(this: RouteBuilder<TBody, TQuery, false, boolean, boolean>, fn: never): never;
295
369
  handler(this: RouteBuilder<TBody, TQuery, True, False, HasBody>, fn: (ctx: HandlerContext<TBody, TQuery>) => Promise<unknown>): (request: NextRequest) => Promise<Response>;
@@ -313,4 +387,4 @@ interface ServiceRouter {
313
387
  }
314
388
  declare function createRouter(config: RouterConfig): ServiceRouter;
315
389
 
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 };
390
+ 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 };