@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.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")) {
@@ -433,21 +496,47 @@ function buildMPPReceipt(reference) {
433
496
  }
434
497
 
435
498
  // src/auth/siwx.ts
499
+ var SIWX_ERROR_MESSAGES = {
500
+ siwx_missing_header: "Missing SIGN-IN-WITH-X header",
501
+ siwx_malformed: "Malformed SIWX payload",
502
+ siwx_expired: "SIWX message expired \u2014 request a new challenge",
503
+ siwx_nonce_used: "Nonce already used \u2014 request a new challenge",
504
+ siwx_invalid_signature: "Invalid signature \u2014 wallet mismatch or corrupted proof"
505
+ };
506
+ function categorizeValidationError(error) {
507
+ if (!error) return "siwx_malformed";
508
+ const err = error.toLowerCase();
509
+ if (err.includes("expired") || err.includes("message too old")) {
510
+ return "siwx_expired";
511
+ }
512
+ if (err.includes("nonce validation failed")) {
513
+ return "siwx_nonce_used";
514
+ }
515
+ return "siwx_malformed";
516
+ }
436
517
  async function verifySIWX(request, _routeEntry, nonceStore) {
437
518
  const { parseSIWxHeader, validateSIWxMessage, verifySIWxSignature } = await import("@x402/extensions/sign-in-with-x");
438
519
  const header = request.headers.get("SIGN-IN-WITH-X");
439
- if (!header) return { valid: false, wallet: null };
440
- const payload = parseSIWxHeader(header);
520
+ if (!header) {
521
+ return { valid: false, wallet: null, code: "siwx_missing_header" };
522
+ }
523
+ let payload;
524
+ try {
525
+ payload = parseSIWxHeader(header);
526
+ } catch {
527
+ return { valid: false, wallet: null, code: "siwx_malformed" };
528
+ }
441
529
  const uri = request.url;
442
530
  const validation = await validateSIWxMessage(payload, uri, {
443
531
  checkNonce: (nonce) => nonceStore.check(nonce)
444
532
  });
445
533
  if (!validation.valid) {
446
- return { valid: false, wallet: null };
534
+ const code = categorizeValidationError(validation.error);
535
+ return { valid: false, wallet: null, code };
447
536
  }
448
537
  const verified = await verifySIWxSignature(payload);
449
538
  if (!verified?.valid) {
450
- return { valid: false, wallet: null };
539
+ return { valid: false, wallet: null, code: "siwx_invalid_signature" };
451
540
  }
452
541
  return { valid: true, wallet: verified.address };
453
542
  }
@@ -521,6 +610,15 @@ function createRequestHandler(routeEntry, handler, deps) {
521
610
  firePluginResponse(deps, pluginCtx, meta, body2.response);
522
611
  return body2.response;
523
612
  }
613
+ if (routeEntry.validateFn) {
614
+ try {
615
+ await routeEntry.validateFn(body2.data);
616
+ } catch (err) {
617
+ const status = err.status ?? 400;
618
+ const message = err instanceof Error ? err.message : "Validation failed";
619
+ return fail(status, message, meta, pluginCtx);
620
+ }
621
+ }
524
622
  const { response, rawResult } = await invoke(
525
623
  request,
526
624
  meta,
@@ -545,13 +643,20 @@ function createRequestHandler(routeEntry, handler, deps) {
545
643
  return fail(401, "Invalid or missing API key", meta, pluginCtx);
546
644
  }
547
645
  account = keyResult.account;
646
+ firePluginHook(deps.plugin, "onAuthVerified", pluginCtx, {
647
+ authMode: "apiKey",
648
+ wallet: null,
649
+ route: routeEntry.key,
650
+ account
651
+ });
548
652
  if (routeEntry.authMode === "apiKey" && !routeEntry.pricing) {
549
653
  return handleAuth(null, account);
550
654
  }
551
655
  }
552
656
  const protocol = detectProtocol(request);
553
657
  let earlyBodyData;
554
- if (!protocol && typeof routeEntry.pricing === "function" && routeEntry.bodySchema) {
658
+ const needsEarlyParse = !protocol && routeEntry.bodySchema && (typeof routeEntry.pricing === "function" || routeEntry.validateFn);
659
+ if (needsEarlyParse) {
555
660
  const requestForPricing = request.clone();
556
661
  const earlyBodyResult = await parseBody(requestForPricing, routeEntry);
557
662
  if (!earlyBodyResult.ok) {
@@ -559,11 +664,35 @@ function createRequestHandler(routeEntry, handler, deps) {
559
664
  return earlyBodyResult.response;
560
665
  }
561
666
  earlyBodyData = earlyBodyResult.data;
667
+ if (routeEntry.validateFn) {
668
+ try {
669
+ await routeEntry.validateFn(earlyBodyData);
670
+ } catch (err) {
671
+ const status = err.status ?? 400;
672
+ const message = err instanceof Error ? err.message : "Validation failed";
673
+ return fail(status, message, meta, pluginCtx);
674
+ }
675
+ }
562
676
  }
563
677
  if (routeEntry.authMode === "siwx") {
678
+ if (routeEntry.validateFn && routeEntry.bodySchema && !request.headers.get("SIGN-IN-WITH-X")) {
679
+ const requestForValidation = request.clone();
680
+ const earlyBodyResult = await parseBody(requestForValidation, routeEntry);
681
+ if (!earlyBodyResult.ok) {
682
+ firePluginResponse(deps, pluginCtx, meta, earlyBodyResult.response);
683
+ return earlyBodyResult.response;
684
+ }
685
+ try {
686
+ await routeEntry.validateFn(earlyBodyResult.data);
687
+ } catch (err) {
688
+ const status = err.status ?? 400;
689
+ const message = err instanceof Error ? err.message : "Validation failed";
690
+ return fail(status, message, meta, pluginCtx);
691
+ }
692
+ }
564
693
  if (!request.headers.get("SIGN-IN-WITH-X")) {
565
694
  const url = new URL(request.url);
566
- const nonce = crypto.randomUUID();
695
+ const nonce = crypto.randomUUID().replace(/-/g, "");
567
696
  const siwxInfo = {
568
697
  domain: url.hostname,
569
698
  uri: request.url,
@@ -572,7 +701,7 @@ function createRequestHandler(routeEntry, handler, deps) {
572
701
  type: "eip191",
573
702
  nonce,
574
703
  issuedAt: (/* @__PURE__ */ new Date()).toISOString(),
575
- expirationTime: new Date(Date.now() + 3e5).toISOString(),
704
+ expirationTime: new Date(Date.now() + SIWX_CHALLENGE_EXPIRY_MS).toISOString(),
576
705
  statement: "Sign in to verify your wallet identity"
577
706
  };
578
707
  let siwxSchema;
@@ -592,6 +721,8 @@ function createRequestHandler(routeEntry, handler, deps) {
592
721
  extensions: {
593
722
  "sign-in-with-x": {
594
723
  info: siwxInfo,
724
+ // supportedChains at top level required by MCP tools for chain detection
725
+ supportedChains: [{ chainId: deps.network, type: "eip191" }],
595
726
  ...siwxSchema ? { schema: siwxSchema } : {}
596
727
  }
597
728
  }
@@ -617,15 +748,21 @@ function createRequestHandler(routeEntry, handler, deps) {
617
748
  }
618
749
  const siwx = await verifySIWX(request, routeEntry, deps.nonceStore);
619
750
  if (!siwx.valid) {
620
- return fail(
621
- 402,
622
- "SIWX verification failed \u2014 signature invalid, message expired, or nonce already used",
623
- meta,
624
- pluginCtx
751
+ const response = NextResponse2.json(
752
+ { error: siwx.code, message: SIWX_ERROR_MESSAGES[siwx.code] },
753
+ { status: 402 }
625
754
  );
755
+ firePluginResponse(deps, pluginCtx, meta, response);
756
+ return response;
626
757
  }
627
- pluginCtx.setVerifiedWallet(siwx.wallet);
628
- return handleAuth(siwx.wallet, void 0);
758
+ const wallet = siwx.wallet.toLowerCase();
759
+ pluginCtx.setVerifiedWallet(wallet);
760
+ firePluginHook(deps.plugin, "onAuthVerified", pluginCtx, {
761
+ authMode: "siwx",
762
+ wallet,
763
+ route: routeEntry.key
764
+ });
765
+ return handleAuth(wallet, void 0);
629
766
  }
630
767
  if (!protocol || protocol === "siwx") {
631
768
  return await build402(request, routeEntry, deps, meta, pluginCtx, earlyBodyData);
@@ -635,6 +772,15 @@ function createRequestHandler(routeEntry, handler, deps) {
635
772
  firePluginResponse(deps, pluginCtx, meta, body.response);
636
773
  return body.response;
637
774
  }
775
+ if (routeEntry.validateFn) {
776
+ try {
777
+ await routeEntry.validateFn(body.data);
778
+ } catch (err) {
779
+ const status = err.status ?? 400;
780
+ const message = err instanceof Error ? err.message : "Validation failed";
781
+ return fail(status, message, meta, pluginCtx);
782
+ }
783
+ }
638
784
  let price;
639
785
  try {
640
786
  price = await resolvePrice(routeEntry.pricing, body.data);
@@ -661,10 +807,11 @@ function createRequestHandler(routeEntry, handler, deps) {
661
807
  );
662
808
  if (!verify?.valid) return await build402(request, routeEntry, deps, meta, pluginCtx);
663
809
  const { payload: verifyPayload, requirements: verifyRequirements } = verify;
664
- pluginCtx.setVerifiedWallet(verify.payer);
810
+ const wallet = verify.payer.toLowerCase();
811
+ pluginCtx.setVerifiedWallet(wallet);
665
812
  firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
666
813
  protocol: "x402",
667
- payer: verify.payer,
814
+ payer: wallet,
668
815
  amount: price,
669
816
  network: deps.network
670
817
  });
@@ -672,7 +819,7 @@ function createRequestHandler(routeEntry, handler, deps) {
672
819
  request,
673
820
  meta,
674
821
  pluginCtx,
675
- verify.payer,
822
+ wallet,
676
823
  account,
677
824
  body.data
678
825
  );
@@ -723,7 +870,7 @@ function createRequestHandler(routeEntry, handler, deps) {
723
870
  if (!deps.mppConfig) return await build402(request, routeEntry, deps, meta, pluginCtx);
724
871
  const verify = await verifyMPPCredential(request, routeEntry, deps.mppConfig, price);
725
872
  if (!verify?.valid) return await build402(request, routeEntry, deps, meta, pluginCtx);
726
- const wallet = verify.payer;
873
+ const wallet = verify.payer.toLowerCase();
727
874
  pluginCtx.setVerifiedWallet(wallet);
728
875
  firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
729
876
  protocol: "mpp",
@@ -982,6 +1129,8 @@ var RouteBuilder = class {
982
1129
  _providerName;
983
1130
  /** @internal */
984
1131
  _providerConfig;
1132
+ /** @internal */
1133
+ _validateFn;
985
1134
  constructor(key, registry, deps) {
986
1135
  this._key = key;
987
1136
  this._registry = registry;
@@ -994,6 +1143,11 @@ var RouteBuilder = class {
994
1143
  return next;
995
1144
  }
996
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
+ }
997
1151
  const next = this.fork();
998
1152
  next._authMode = "paid";
999
1153
  next._pricing = pricing;
@@ -1023,6 +1177,11 @@ var RouteBuilder = class {
1023
1177
  return next;
1024
1178
  }
1025
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
+ }
1026
1185
  const next = this.fork();
1027
1186
  next._authMode = "siwx";
1028
1187
  next._protocols = [];
@@ -1083,7 +1242,41 @@ var RouteBuilder = class {
1083
1242
  next._method = m;
1084
1243
  return next;
1085
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
+ }
1086
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
+ }
1087
1280
  const entry = {
1088
1281
  key: this._key,
1089
1282
  authMode: this._authMode,
@@ -1098,30 +1291,14 @@ var RouteBuilder = class {
1098
1291
  maxPrice: this._maxPrice,
1099
1292
  apiKeyResolver: this._apiKeyResolver,
1100
1293
  providerName: this._providerName,
1101
- providerConfig: this._providerConfig
1294
+ providerConfig: this._providerConfig,
1295
+ validateFn: this._validateFn
1102
1296
  };
1103
1297
  this._registry.register(entry);
1104
1298
  return createRequestHandler(entry, fn, this._deps);
1105
1299
  }
1106
1300
  };
1107
1301
 
1108
- // src/auth/nonce.ts
1109
- var MemoryNonceStore = class {
1110
- seen = /* @__PURE__ */ new Map();
1111
- async check(nonce) {
1112
- this.evict();
1113
- if (this.seen.has(nonce)) return false;
1114
- this.seen.set(nonce, Date.now() + 5 * 60 * 1e3);
1115
- return true;
1116
- }
1117
- evict() {
1118
- const now = Date.now();
1119
- for (const [n, exp] of this.seen) {
1120
- if (exp < now) this.seen.delete(n);
1121
- }
1122
- }
1123
- };
1124
-
1125
1302
  // src/discovery/well-known.ts
1126
1303
  import { NextResponse as NextResponse3 } from "next/server";
1127
1304
  function createWellKnownHandler(registry, baseUrl, pricesKeys, options = {}) {
@@ -1353,6 +1530,9 @@ export {
1353
1530
  MemoryNonceStore,
1354
1531
  RouteBuilder,
1355
1532
  RouteRegistry,
1533
+ SIWX_CHALLENGE_EXPIRY_MS,
1534
+ SIWX_ERROR_MESSAGES,
1356
1535
  consolePlugin,
1536
+ createRedisNonceStore,
1357
1537
  createRouter
1358
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.6",
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": {