@agentcash/router 0.4.6 → 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")) {
@@ -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",
@@ -1016,6 +1166,8 @@ var RouteBuilder = class {
1016
1166
  _providerName;
1017
1167
  /** @internal */
1018
1168
  _providerConfig;
1169
+ /** @internal */
1170
+ _validateFn;
1019
1171
  constructor(key, registry, deps) {
1020
1172
  this._key = key;
1021
1173
  this._registry = registry;
@@ -1028,6 +1180,11 @@ var RouteBuilder = class {
1028
1180
  return next;
1029
1181
  }
1030
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
+ }
1031
1188
  const next = this.fork();
1032
1189
  next._authMode = "paid";
1033
1190
  next._pricing = pricing;
@@ -1057,6 +1214,11 @@ var RouteBuilder = class {
1057
1214
  return next;
1058
1215
  }
1059
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
+ }
1060
1222
  const next = this.fork();
1061
1223
  next._authMode = "siwx";
1062
1224
  next._protocols = [];
@@ -1117,7 +1279,41 @@ var RouteBuilder = class {
1117
1279
  next._method = m;
1118
1280
  return next;
1119
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
+ }
1120
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
+ }
1121
1317
  const entry = {
1122
1318
  key: this._key,
1123
1319
  authMode: this._authMode,
@@ -1132,30 +1328,14 @@ var RouteBuilder = class {
1132
1328
  maxPrice: this._maxPrice,
1133
1329
  apiKeyResolver: this._apiKeyResolver,
1134
1330
  providerName: this._providerName,
1135
- providerConfig: this._providerConfig
1331
+ providerConfig: this._providerConfig,
1332
+ validateFn: this._validateFn
1136
1333
  };
1137
1334
  this._registry.register(entry);
1138
1335
  return createRequestHandler(entry, fn, this._deps);
1139
1336
  }
1140
1337
  };
1141
1338
 
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
1339
  // src/discovery/well-known.ts
1160
1340
  var import_server4 = require("next/server");
1161
1341
  function createWellKnownHandler(registry, baseUrl, pricesKeys, options = {}) {
@@ -1388,6 +1568,9 @@ function createRouter(config) {
1388
1568
  MemoryNonceStore,
1389
1569
  RouteBuilder,
1390
1570
  RouteRegistry,
1571
+ SIWX_CHALLENGE_EXPIRY_MS,
1572
+ SIWX_ERROR_MESSAGES,
1391
1573
  consolePlugin,
1574
+ createRedisNonceStore,
1392
1575
  createRouter
1393
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 };
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;
@@ -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 };