@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/.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 +245 -38
- package/dist/index.d.cts +75 -1
- package/dist/index.d.ts +75 -1
- package/dist/index.js +242 -38
- package/dist/siwx-BMlja_nt.d.cts +9 -0
- package/dist/siwx-BMlja_nt.d.ts +9 -0
- package/package.json +1 -1
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)
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() +
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
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 };
|