@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.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 };
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")) {
@@ -320,6 +383,12 @@ async function verifyX402Payment(server, request, routeEntry, price, payeeAddres
320
383
  }
321
384
  async function settleX402Payment(server, payload, requirements) {
322
385
  const { encodePaymentResponseHeader } = await import("@x402/core/http");
386
+ const payloadKeys = typeof payload === "object" && payload !== null ? Object.keys(payload).sort().join(",") : "n/a";
387
+ const reqKeys = typeof requirements === "object" && requirements !== null ? Object.keys(requirements).sort().join(",") : "n/a";
388
+ console.info("x402 settle input", {
389
+ payloadKeys,
390
+ requirementsKeys: reqKeys
391
+ });
323
392
  const result = await server.settlePayment(payload, requirements);
324
393
  const encoded = encodePaymentResponseHeader(result);
325
394
  return { encoded, result };
@@ -427,21 +496,47 @@ function buildMPPReceipt(reference) {
427
496
  }
428
497
 
429
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
+ }
430
517
  async function verifySIWX(request, _routeEntry, nonceStore) {
431
518
  const { parseSIWxHeader, validateSIWxMessage, verifySIWxSignature } = await import("@x402/extensions/sign-in-with-x");
432
519
  const header = request.headers.get("SIGN-IN-WITH-X");
433
- if (!header) return { valid: false, wallet: null };
434
- 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
+ }
435
529
  const uri = request.url;
436
530
  const validation = await validateSIWxMessage(payload, uri, {
437
531
  checkNonce: (nonce) => nonceStore.check(nonce)
438
532
  });
439
533
  if (!validation.valid) {
440
- return { valid: false, wallet: null };
534
+ const code = categorizeValidationError(validation.error);
535
+ return { valid: false, wallet: null, code };
441
536
  }
442
537
  const verified = await verifySIWxSignature(payload);
443
538
  if (!verified?.valid) {
444
- return { valid: false, wallet: null };
539
+ return { valid: false, wallet: null, code: "siwx_invalid_signature" };
445
540
  }
446
541
  return { valid: true, wallet: verified.address };
447
542
  }
@@ -515,6 +610,15 @@ function createRequestHandler(routeEntry, handler, deps) {
515
610
  firePluginResponse(deps, pluginCtx, meta, body2.response);
516
611
  return body2.response;
517
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
+ }
518
622
  const { response, rawResult } = await invoke(
519
623
  request,
520
624
  meta,
@@ -539,13 +643,20 @@ function createRequestHandler(routeEntry, handler, deps) {
539
643
  return fail(401, "Invalid or missing API key", meta, pluginCtx);
540
644
  }
541
645
  account = keyResult.account;
646
+ firePluginHook(deps.plugin, "onAuthVerified", pluginCtx, {
647
+ authMode: "apiKey",
648
+ wallet: null,
649
+ route: routeEntry.key,
650
+ account
651
+ });
542
652
  if (routeEntry.authMode === "apiKey" && !routeEntry.pricing) {
543
653
  return handleAuth(null, account);
544
654
  }
545
655
  }
546
656
  const protocol = detectProtocol(request);
547
657
  let earlyBodyData;
548
- if (!protocol && typeof routeEntry.pricing === "function" && routeEntry.bodySchema) {
658
+ const needsEarlyParse = !protocol && routeEntry.bodySchema && (typeof routeEntry.pricing === "function" || routeEntry.validateFn);
659
+ if (needsEarlyParse) {
549
660
  const requestForPricing = request.clone();
550
661
  const earlyBodyResult = await parseBody(requestForPricing, routeEntry);
551
662
  if (!earlyBodyResult.ok) {
@@ -553,11 +664,35 @@ function createRequestHandler(routeEntry, handler, deps) {
553
664
  return earlyBodyResult.response;
554
665
  }
555
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
+ }
556
676
  }
557
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
+ }
558
693
  if (!request.headers.get("SIGN-IN-WITH-X")) {
559
694
  const url = new URL(request.url);
560
- const nonce = crypto.randomUUID();
695
+ const nonce = crypto.randomUUID().replace(/-/g, "");
561
696
  const siwxInfo = {
562
697
  domain: url.hostname,
563
698
  uri: request.url,
@@ -566,7 +701,7 @@ function createRequestHandler(routeEntry, handler, deps) {
566
701
  type: "eip191",
567
702
  nonce,
568
703
  issuedAt: (/* @__PURE__ */ new Date()).toISOString(),
569
- expirationTime: new Date(Date.now() + 3e5).toISOString(),
704
+ expirationTime: new Date(Date.now() + SIWX_CHALLENGE_EXPIRY_MS).toISOString(),
570
705
  statement: "Sign in to verify your wallet identity"
571
706
  };
572
707
  let siwxSchema;
@@ -586,6 +721,8 @@ function createRequestHandler(routeEntry, handler, deps) {
586
721
  extensions: {
587
722
  "sign-in-with-x": {
588
723
  info: siwxInfo,
724
+ // supportedChains at top level required by MCP tools for chain detection
725
+ supportedChains: [{ chainId: deps.network, type: "eip191" }],
589
726
  ...siwxSchema ? { schema: siwxSchema } : {}
590
727
  }
591
728
  }
@@ -611,15 +748,21 @@ function createRequestHandler(routeEntry, handler, deps) {
611
748
  }
612
749
  const siwx = await verifySIWX(request, routeEntry, deps.nonceStore);
613
750
  if (!siwx.valid) {
614
- return fail(
615
- 402,
616
- "SIWX verification failed \u2014 signature invalid, message expired, or nonce already used",
617
- meta,
618
- pluginCtx
751
+ const response = NextResponse2.json(
752
+ { error: siwx.code, message: SIWX_ERROR_MESSAGES[siwx.code] },
753
+ { status: 402 }
619
754
  );
755
+ firePluginResponse(deps, pluginCtx, meta, response);
756
+ return response;
620
757
  }
621
- pluginCtx.setVerifiedWallet(siwx.wallet);
622
- 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);
623
766
  }
624
767
  if (!protocol || protocol === "siwx") {
625
768
  return await build402(request, routeEntry, deps, meta, pluginCtx, earlyBodyData);
@@ -629,6 +772,15 @@ function createRequestHandler(routeEntry, handler, deps) {
629
772
  firePluginResponse(deps, pluginCtx, meta, body.response);
630
773
  return body.response;
631
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
+ }
632
784
  let price;
633
785
  try {
634
786
  price = await resolvePrice(routeEntry.pricing, body.data);
@@ -654,10 +806,12 @@ function createRequestHandler(routeEntry, handler, deps) {
654
806
  deps.network
655
807
  );
656
808
  if (!verify?.valid) return await build402(request, routeEntry, deps, meta, pluginCtx);
657
- pluginCtx.setVerifiedWallet(verify.payer);
809
+ const { payload: verifyPayload, requirements: verifyRequirements } = verify;
810
+ const wallet = verify.payer.toLowerCase();
811
+ pluginCtx.setVerifiedWallet(wallet);
658
812
  firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
659
813
  protocol: "x402",
660
- payer: verify.payer,
814
+ payer: wallet,
661
815
  amount: price,
662
816
  network: deps.network
663
817
  });
@@ -665,16 +819,25 @@ function createRequestHandler(routeEntry, handler, deps) {
665
819
  request,
666
820
  meta,
667
821
  pluginCtx,
668
- verify.payer,
822
+ wallet,
669
823
  account,
670
824
  body.data
671
825
  );
672
826
  if (response.status < 400) {
673
827
  try {
828
+ const payloadFingerprint = typeof verifyPayload === "object" && verifyPayload !== null ? {
829
+ keys: Object.keys(verifyPayload).sort().join(","),
830
+ payloadType: typeof verifyPayload
831
+ } : { payloadType: typeof verifyPayload };
832
+ console.info("Settlement attempt", {
833
+ route: routeEntry.key,
834
+ network: deps.network,
835
+ ...payloadFingerprint
836
+ });
674
837
  const settle = await settleX402Payment(
675
838
  deps.x402Server,
676
- verify.payload,
677
- verify.requirements
839
+ verifyPayload,
840
+ verifyRequirements
678
841
  );
679
842
  response.headers.set("PAYMENT-RESPONSE", settle.encoded);
680
843
  firePluginHook(deps.plugin, "onPaymentSettled", pluginCtx, {
@@ -684,6 +847,14 @@ function createRequestHandler(routeEntry, handler, deps) {
684
847
  network: deps.network
685
848
  });
686
849
  } catch (err) {
850
+ const errObj = err;
851
+ console.error("Settlement failed", {
852
+ message: err instanceof Error ? err.message : String(err),
853
+ route: routeEntry.key,
854
+ network: deps.network,
855
+ facilitatorStatus: errObj.response?.status,
856
+ facilitatorBody: errObj.response?.data ?? errObj.response?.body
857
+ });
687
858
  firePluginHook(deps.plugin, "onAlert", pluginCtx, {
688
859
  level: "critical",
689
860
  message: `Settlement failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -699,7 +870,7 @@ function createRequestHandler(routeEntry, handler, deps) {
699
870
  if (!deps.mppConfig) return await build402(request, routeEntry, deps, meta, pluginCtx);
700
871
  const verify = await verifyMPPCredential(request, routeEntry, deps.mppConfig, price);
701
872
  if (!verify?.valid) return await build402(request, routeEntry, deps, meta, pluginCtx);
702
- const wallet = verify.payer;
873
+ const wallet = verify.payer.toLowerCase();
703
874
  pluginCtx.setVerifiedWallet(wallet);
704
875
  firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
705
876
  protocol: "mpp",
@@ -958,6 +1129,8 @@ var RouteBuilder = class {
958
1129
  _providerName;
959
1130
  /** @internal */
960
1131
  _providerConfig;
1132
+ /** @internal */
1133
+ _validateFn;
961
1134
  constructor(key, registry, deps) {
962
1135
  this._key = key;
963
1136
  this._registry = registry;
@@ -970,6 +1143,11 @@ var RouteBuilder = class {
970
1143
  return next;
971
1144
  }
972
1145
  paid(pricing, options) {
1146
+ if (this._authMode === "siwx") {
1147
+ throw new Error(
1148
+ `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.`
1149
+ );
1150
+ }
973
1151
  const next = this.fork();
974
1152
  next._authMode = "paid";
975
1153
  next._pricing = pricing;
@@ -999,6 +1177,11 @@ var RouteBuilder = class {
999
1177
  return next;
1000
1178
  }
1001
1179
  siwx() {
1180
+ if (this._authMode === "paid") {
1181
+ throw new Error(
1182
+ `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.`
1183
+ );
1184
+ }
1002
1185
  const next = this.fork();
1003
1186
  next._authMode = "siwx";
1004
1187
  next._protocols = [];
@@ -1059,7 +1242,41 @@ var RouteBuilder = class {
1059
1242
  next._method = m;
1060
1243
  return next;
1061
1244
  }
1245
+ // -------------------------------------------------------------------------
1246
+ // Pre-payment validation
1247
+ // -------------------------------------------------------------------------
1248
+ /**
1249
+ * Add pre-payment validation that runs after body parsing but before the 402
1250
+ * challenge is shown. Use this for async business logic like "is this resource
1251
+ * available?" or "has this user hit their rate limit?".
1252
+ *
1253
+ * Requires `.body()` — call `.body()` before `.validate()` for type inference.
1254
+ *
1255
+ * @example
1256
+ * ```typescript
1257
+ * router
1258
+ * .route('domain/register')
1259
+ * .paid(calculatePrice)
1260
+ * .body(RegisterSchema) // .body() first for type inference
1261
+ * .validate(async (body) => {
1262
+ * if (await isDomainTaken(body.domain)) {
1263
+ * throw Object.assign(new Error('Domain taken'), { status: 409 });
1264
+ * }
1265
+ * })
1266
+ * .handler(async ({ body }) => { ... });
1267
+ * ```
1268
+ */
1269
+ validate(fn) {
1270
+ const next = this.fork();
1271
+ next._validateFn = fn;
1272
+ return next;
1273
+ }
1062
1274
  handler(fn) {
1275
+ if (this._validateFn && !this._bodySchema) {
1276
+ throw new Error(
1277
+ `route '${this._key}': .validate() requires .body() \u2014 validation runs on parsed body`
1278
+ );
1279
+ }
1063
1280
  const entry = {
1064
1281
  key: this._key,
1065
1282
  authMode: this._authMode,
@@ -1074,30 +1291,14 @@ var RouteBuilder = class {
1074
1291
  maxPrice: this._maxPrice,
1075
1292
  apiKeyResolver: this._apiKeyResolver,
1076
1293
  providerName: this._providerName,
1077
- providerConfig: this._providerConfig
1294
+ providerConfig: this._providerConfig,
1295
+ validateFn: this._validateFn
1078
1296
  };
1079
1297
  this._registry.register(entry);
1080
1298
  return createRequestHandler(entry, fn, this._deps);
1081
1299
  }
1082
1300
  };
1083
1301
 
1084
- // src/auth/nonce.ts
1085
- var MemoryNonceStore = class {
1086
- seen = /* @__PURE__ */ new Map();
1087
- async check(nonce) {
1088
- this.evict();
1089
- if (this.seen.has(nonce)) return false;
1090
- this.seen.set(nonce, Date.now() + 5 * 60 * 1e3);
1091
- return true;
1092
- }
1093
- evict() {
1094
- const now = Date.now();
1095
- for (const [n, exp] of this.seen) {
1096
- if (exp < now) this.seen.delete(n);
1097
- }
1098
- }
1099
- };
1100
-
1101
1302
  // src/discovery/well-known.ts
1102
1303
  import { NextResponse as NextResponse3 } from "next/server";
1103
1304
  function createWellKnownHandler(registry, baseUrl, pricesKeys, options = {}) {
@@ -1329,6 +1530,9 @@ export {
1329
1530
  MemoryNonceStore,
1330
1531
  RouteBuilder,
1331
1532
  RouteRegistry,
1533
+ SIWX_CHALLENGE_EXPIRY_MS,
1534
+ SIWX_ERROR_MESSAGES,
1332
1535
  consolePlugin,
1536
+ createRedisNonceStore,
1333
1537
  createRouter
1334
1538
  };
@@ -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.5",
3
+ "version": "0.4.7",
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": {