@agentcash/router 0.4.6 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/CLAUDE.md +129 -0
- package/dist/client/index.cjs +94 -0
- package/dist/client/index.d.cts +86 -0
- package/dist/client/index.d.ts +86 -0
- package/dist/client/index.js +56 -0
- package/dist/index.cjs +234 -40
- package/dist/index.d.cts +78 -1
- package/dist/index.d.ts +78 -1
- package/dist/index.js +231 -40
- package/dist/siwx-BMlja_nt.d.cts +9 -0
- package/dist/siwx-BMlja_nt.d.ts +9 -0
- package/package.json +3 -3
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;
|
|
@@ -120,6 +170,7 @@ type PricingConfig<TBody = unknown> = string | ((body: TBody) => string | Promis
|
|
|
120
170
|
interface PaidOptions {
|
|
121
171
|
protocols?: ProtocolType[];
|
|
122
172
|
maxPrice?: string;
|
|
173
|
+
minPrice?: string;
|
|
123
174
|
}
|
|
124
175
|
interface HandlerContext<TBody = undefined, TQuery = undefined> {
|
|
125
176
|
body: TBody;
|
|
@@ -168,9 +219,11 @@ interface RouteEntry {
|
|
|
168
219
|
path?: string;
|
|
169
220
|
method: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH';
|
|
170
221
|
maxPrice?: string;
|
|
222
|
+
minPrice?: string;
|
|
171
223
|
apiKeyResolver?: (key: string) => unknown | Promise<unknown>;
|
|
172
224
|
providerName?: string;
|
|
173
225
|
providerConfig?: ProviderConfig;
|
|
226
|
+
validateFn?: (body: unknown) => void | Promise<void>;
|
|
174
227
|
}
|
|
175
228
|
interface RouterConfig {
|
|
176
229
|
payeeAddress: string;
|
|
@@ -257,6 +310,7 @@ declare class RouteBuilder<TBody = undefined, TQuery = undefined, HasAuth extend
|
|
|
257
310
|
/** @internal */ _pricing: PricingConfig | undefined;
|
|
258
311
|
/** @internal */ _protocols: ProtocolType[];
|
|
259
312
|
/** @internal */ _maxPrice: string | undefined;
|
|
313
|
+
/** @internal */ _minPrice: string | undefined;
|
|
260
314
|
/** @internal */ _bodySchema: ZodType | undefined;
|
|
261
315
|
/** @internal */ _querySchema: ZodType | undefined;
|
|
262
316
|
/** @internal */ _outputSchema: ZodType | undefined;
|
|
@@ -266,6 +320,7 @@ declare class RouteBuilder<TBody = undefined, TQuery = undefined, HasAuth extend
|
|
|
266
320
|
/** @internal */ _apiKeyResolver: ((key: string) => unknown | Promise<unknown>) | undefined;
|
|
267
321
|
/** @internal */ _providerName: string | undefined;
|
|
268
322
|
/** @internal */ _providerConfig: ProviderConfig | undefined;
|
|
323
|
+
/** @internal */ _validateFn: ((body: TBody) => void | Promise<void>) | undefined;
|
|
269
324
|
constructor(key: string, registry: RouteRegistry, deps: OrchestrateDeps);
|
|
270
325
|
private fork;
|
|
271
326
|
paid(pricing: string, options?: PaidOptions): RouteBuilder<TBody, TQuery, True, False, HasBody>;
|
|
@@ -290,6 +345,28 @@ declare class RouteBuilder<TBody = undefined, TQuery = undefined, HasAuth extend
|
|
|
290
345
|
description(text: string): this;
|
|
291
346
|
path(p: string): this;
|
|
292
347
|
method(m: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH'): this;
|
|
348
|
+
/**
|
|
349
|
+
* Add pre-payment validation that runs after body parsing but before the 402
|
|
350
|
+
* challenge is shown. Use this for async business logic like "is this resource
|
|
351
|
+
* available?" or "has this user hit their rate limit?".
|
|
352
|
+
*
|
|
353
|
+
* Requires `.body()` — call `.body()` before `.validate()` for type inference.
|
|
354
|
+
*
|
|
355
|
+
* @example
|
|
356
|
+
* ```typescript
|
|
357
|
+
* router
|
|
358
|
+
* .route('domain/register')
|
|
359
|
+
* .paid(calculatePrice)
|
|
360
|
+
* .body(RegisterSchema) // .body() first for type inference
|
|
361
|
+
* .validate(async (body) => {
|
|
362
|
+
* if (await isDomainTaken(body.domain)) {
|
|
363
|
+
* throw Object.assign(new Error('Domain taken'), { status: 409 });
|
|
364
|
+
* }
|
|
365
|
+
* })
|
|
366
|
+
* .handler(async ({ body }) => { ... });
|
|
367
|
+
* ```
|
|
368
|
+
*/
|
|
369
|
+
validate(fn: (body: TBody) => void | Promise<void>): RouteBuilder<TBody, TQuery, HasAuth, NeedsBody, HasBody>;
|
|
293
370
|
handler(this: RouteBuilder<TBody, TQuery, True, true, false>, fn: never): never;
|
|
294
371
|
handler(this: RouteBuilder<TBody, TQuery, false, boolean, boolean>, fn: never): never;
|
|
295
372
|
handler(this: RouteBuilder<TBody, TQuery, True, False, HasBody>, fn: (ctx: HandlerContext<TBody, TQuery>) => Promise<unknown>): (request: NextRequest) => Promise<Response>;
|
|
@@ -313,4 +390,4 @@ interface ServiceRouter {
|
|
|
313
390
|
}
|
|
314
391
|
declare function createRouter(config: RouterConfig): ServiceRouter;
|
|
315
392
|
|
|
316
|
-
export { type AlertEvent, type AlertFn, type AlertLevel, type AuthMode, type ErrorEvent, type HandlerContext, HttpError, MemoryNonceStore, type MonitorEntry, type NonceStore, type OveragePolicy, type PaidOptions, type PaymentEvent, type PluginContext, type PricingConfig, type ProtocolType, type ProviderConfig, type ProviderQuotaEvent, type QuotaInfo, type QuotaLevel, type RequestMeta, type ResponseMeta, RouteBuilder, type RouteEntry, RouteRegistry, type RouterConfig, type RouterPlugin, type ServiceRouter, type SettlementEvent, type TierConfig, type X402Server, consolePlugin, createRouter };
|
|
393
|
+
export { type AlertEvent, type AlertFn, type AlertLevel, type AuthEvent, type AuthMode, type ErrorEvent, type HandlerContext, HttpError, MemoryNonceStore, type MonitorEntry, type NonceStore, type OveragePolicy, type PaidOptions, type PaymentEvent, type PluginContext, type PricingConfig, type ProtocolType, type ProviderConfig, type ProviderQuotaEvent, type QuotaInfo, type QuotaLevel, type RedisNonceStoreOptions, type RequestMeta, type ResponseMeta, RouteBuilder, type RouteEntry, RouteRegistry, type RouterConfig, type RouterPlugin, SIWX_CHALLENGE_EXPIRY_MS, type ServiceRouter, type SettlementEvent, type TierConfig, type X402Server, consolePlugin, createRedisNonceStore, createRouter };
|
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")) {
|
|
@@ -334,6 +397,7 @@ async function settleX402Payment(server, payload, requirements) {
|
|
|
334
397
|
// src/protocols/mpp.ts
|
|
335
398
|
import { Challenge, Credential, Receipt } from "mppx";
|
|
336
399
|
import { tempo } from "mppx/server";
|
|
400
|
+
import { Methods } from "mppx/tempo";
|
|
337
401
|
import { createClient, http } from "viem";
|
|
338
402
|
import { tempo as tempoChain } from "viem/chains";
|
|
339
403
|
function buildGetClient(rpcUrl) {
|
|
@@ -357,8 +421,7 @@ async function buildMPPChallenge(routeEntry, request, mppConfig, price) {
|
|
|
357
421
|
const standardRequest = toStandardRequest(request);
|
|
358
422
|
const currency = mppConfig.currency;
|
|
359
423
|
const recipient = mppConfig.recipient ?? "";
|
|
360
|
-
const
|
|
361
|
-
const challenge = Challenge.fromIntent(methodIntent, {
|
|
424
|
+
const challenge = Challenge.fromMethod(Methods.charge, {
|
|
362
425
|
secretKey: mppConfig.secretKey,
|
|
363
426
|
realm: new URL(standardRequest.url).origin,
|
|
364
427
|
request: {
|
|
@@ -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)
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() +
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
810
|
+
const wallet = verify.payer.toLowerCase();
|
|
811
|
+
pluginCtx.setVerifiedWallet(wallet);
|
|
665
812
|
firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
|
|
666
813
|
protocol: "x402",
|
|
667
|
-
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
|
-
|
|
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",
|
|
@@ -965,6 +1112,8 @@ var RouteBuilder = class {
|
|
|
965
1112
|
/** @internal */
|
|
966
1113
|
_maxPrice;
|
|
967
1114
|
/** @internal */
|
|
1115
|
+
_minPrice;
|
|
1116
|
+
/** @internal */
|
|
968
1117
|
_bodySchema;
|
|
969
1118
|
/** @internal */
|
|
970
1119
|
_querySchema;
|
|
@@ -982,6 +1131,8 @@ var RouteBuilder = class {
|
|
|
982
1131
|
_providerName;
|
|
983
1132
|
/** @internal */
|
|
984
1133
|
_providerConfig;
|
|
1134
|
+
/** @internal */
|
|
1135
|
+
_validateFn;
|
|
985
1136
|
constructor(key, registry, deps) {
|
|
986
1137
|
this._key = key;
|
|
987
1138
|
this._registry = registry;
|
|
@@ -994,11 +1145,17 @@ var RouteBuilder = class {
|
|
|
994
1145
|
return next;
|
|
995
1146
|
}
|
|
996
1147
|
paid(pricing, options) {
|
|
1148
|
+
if (this._authMode === "siwx") {
|
|
1149
|
+
throw new Error(
|
|
1150
|
+
`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.`
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
997
1153
|
const next = this.fork();
|
|
998
1154
|
next._authMode = "paid";
|
|
999
1155
|
next._pricing = pricing;
|
|
1000
1156
|
if (options?.protocols) next._protocols = options.protocols;
|
|
1001
1157
|
if (options?.maxPrice) next._maxPrice = options.maxPrice;
|
|
1158
|
+
if (options?.minPrice) next._minPrice = options.minPrice;
|
|
1002
1159
|
if (typeof pricing === "object" && "tiers" in pricing) {
|
|
1003
1160
|
for (const [tierKey, tierConfig] of Object.entries(pricing.tiers)) {
|
|
1004
1161
|
if (!tierKey) {
|
|
@@ -1023,6 +1180,11 @@ var RouteBuilder = class {
|
|
|
1023
1180
|
return next;
|
|
1024
1181
|
}
|
|
1025
1182
|
siwx() {
|
|
1183
|
+
if (this._authMode === "paid") {
|
|
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
|
+
}
|
|
1026
1188
|
const next = this.fork();
|
|
1027
1189
|
next._authMode = "siwx";
|
|
1028
1190
|
next._protocols = [];
|
|
@@ -1083,7 +1245,41 @@ var RouteBuilder = class {
|
|
|
1083
1245
|
next._method = m;
|
|
1084
1246
|
return next;
|
|
1085
1247
|
}
|
|
1248
|
+
// -------------------------------------------------------------------------
|
|
1249
|
+
// Pre-payment validation
|
|
1250
|
+
// -------------------------------------------------------------------------
|
|
1251
|
+
/**
|
|
1252
|
+
* Add pre-payment validation that runs after body parsing but before the 402
|
|
1253
|
+
* challenge is shown. Use this for async business logic like "is this resource
|
|
1254
|
+
* available?" or "has this user hit their rate limit?".
|
|
1255
|
+
*
|
|
1256
|
+
* Requires `.body()` — call `.body()` before `.validate()` for type inference.
|
|
1257
|
+
*
|
|
1258
|
+
* @example
|
|
1259
|
+
* ```typescript
|
|
1260
|
+
* router
|
|
1261
|
+
* .route('domain/register')
|
|
1262
|
+
* .paid(calculatePrice)
|
|
1263
|
+
* .body(RegisterSchema) // .body() first for type inference
|
|
1264
|
+
* .validate(async (body) => {
|
|
1265
|
+
* if (await isDomainTaken(body.domain)) {
|
|
1266
|
+
* throw Object.assign(new Error('Domain taken'), { status: 409 });
|
|
1267
|
+
* }
|
|
1268
|
+
* })
|
|
1269
|
+
* .handler(async ({ body }) => { ... });
|
|
1270
|
+
* ```
|
|
1271
|
+
*/
|
|
1272
|
+
validate(fn) {
|
|
1273
|
+
const next = this.fork();
|
|
1274
|
+
next._validateFn = fn;
|
|
1275
|
+
return next;
|
|
1276
|
+
}
|
|
1086
1277
|
handler(fn) {
|
|
1278
|
+
if (this._validateFn && !this._bodySchema) {
|
|
1279
|
+
throw new Error(
|
|
1280
|
+
`route '${this._key}': .validate() requires .body() \u2014 validation runs on parsed body`
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1087
1283
|
const entry = {
|
|
1088
1284
|
key: this._key,
|
|
1089
1285
|
authMode: this._authMode,
|
|
@@ -1096,32 +1292,17 @@ var RouteBuilder = class {
|
|
|
1096
1292
|
path: this._path,
|
|
1097
1293
|
method: this._method,
|
|
1098
1294
|
maxPrice: this._maxPrice,
|
|
1295
|
+
minPrice: this._minPrice,
|
|
1099
1296
|
apiKeyResolver: this._apiKeyResolver,
|
|
1100
1297
|
providerName: this._providerName,
|
|
1101
|
-
providerConfig: this._providerConfig
|
|
1298
|
+
providerConfig: this._providerConfig,
|
|
1299
|
+
validateFn: this._validateFn
|
|
1102
1300
|
};
|
|
1103
1301
|
this._registry.register(entry);
|
|
1104
1302
|
return createRequestHandler(entry, fn, this._deps);
|
|
1105
1303
|
}
|
|
1106
1304
|
};
|
|
1107
1305
|
|
|
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
1306
|
// src/discovery/well-known.ts
|
|
1126
1307
|
import { NextResponse as NextResponse3 } from "next/server";
|
|
1127
1308
|
function createWellKnownHandler(registry, baseUrl, pricesKeys, options = {}) {
|
|
@@ -1214,9 +1395,16 @@ function buildOperation(routeKey, entry, tag) {
|
|
|
1214
1395
|
const protocols = entry.protocols.length > 0 ? entry.protocols : void 0;
|
|
1215
1396
|
let price;
|
|
1216
1397
|
if (typeof entry.pricing === "string") {
|
|
1217
|
-
price =
|
|
1398
|
+
price = entry.pricing;
|
|
1399
|
+
} else if (typeof entry.pricing === "object" && "tiers" in entry.pricing) {
|
|
1400
|
+
const tierPrices = Object.values(entry.pricing.tiers).map((t) => parseFloat(t.price));
|
|
1401
|
+
const min = Math.min(...tierPrices);
|
|
1402
|
+
const max = Math.max(...tierPrices);
|
|
1403
|
+
price = min === max ? String(min) : `${min}-${max}`;
|
|
1404
|
+
} else if (entry.minPrice && entry.maxPrice) {
|
|
1405
|
+
price = `${entry.minPrice}-${entry.maxPrice}`;
|
|
1218
1406
|
} else if (entry.maxPrice) {
|
|
1219
|
-
price =
|
|
1407
|
+
price = entry.maxPrice;
|
|
1220
1408
|
}
|
|
1221
1409
|
const operation = {
|
|
1222
1410
|
operationId: routeKey.replace(/\//g, "_"),
|
|
@@ -1353,6 +1541,9 @@ export {
|
|
|
1353
1541
|
MemoryNonceStore,
|
|
1354
1542
|
RouteBuilder,
|
|
1355
1543
|
RouteRegistry,
|
|
1544
|
+
SIWX_CHALLENGE_EXPIRY_MS,
|
|
1545
|
+
SIWX_ERROR_MESSAGES,
|
|
1356
1546
|
consolePlugin,
|
|
1547
|
+
createRedisNonceStore,
|
|
1357
1548
|
createRouter
|
|
1358
1549
|
};
|
|
@@ -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.
|
|
3
|
+
"version": "0.4.8",
|
|
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": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@x402/core": "^2.3.0",
|
|
29
29
|
"@x402/evm": "^2.3.0",
|
|
30
30
|
"@x402/extensions": "^2.3.0",
|
|
31
|
-
"mppx": "^0.2.
|
|
31
|
+
"mppx": "^0.2.2",
|
|
32
32
|
"next": ">=15.0.0",
|
|
33
33
|
"zod": "^4.0.0",
|
|
34
34
|
"zod-openapi": "^5.0.0"
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"@x402/evm": "^2.3.0",
|
|
48
48
|
"@x402/extensions": "^2.3.0",
|
|
49
49
|
"eslint": "^10.0.0",
|
|
50
|
-
"mppx": "^0.2.0",
|
|
51
50
|
"next": "^15.0.0",
|
|
51
|
+
"mppx": "^0.2.2",
|
|
52
52
|
"prettier": "^3.8.1",
|
|
53
53
|
"react": "^19.0.0",
|
|
54
54
|
"tsup": "^8.0.0",
|