@agentcash/router 0.3.1 → 0.4.1

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.
@@ -0,0 +1,92 @@
1
+ # CLAUDE.md — @agentcash/router
2
+
3
+ Protocol-agnostic route framework for Next.js App Router APIs with x402 payment, MPP payment, SIWX identity auth, and API key auth. Used by stablestudio, enrichx402, x402email, agentfacilitator, agentupload.
4
+
5
+ ## Guiding Principles
6
+
7
+ 1. **Route definition is 3-6 lines.** Everything else is derived.
8
+ 2. **Single source of truth.** Route registry drives discovery, OpenAPI, pricing, Bazaar schemas.
9
+ 3. **Auth and pricing are orthogonal and composable.** `.paid()`, `.siwx()`, `.apiKey()`, `.unprotected()`.
10
+ 4. **Observability is pluggable via RouterPlugin.** Zero boilerplate.
11
+ 5. **The package owns the x402 server lifecycle.** Init, verify, settle.
12
+ 6. **Convention over configuration.** Sane defaults for Base, USDC, exact scheme.
13
+ 7. **Compose, don't reimplement.** Zero payment/auth protocol logic — delegates to `@x402/*` and `@coinbase/x402`.
14
+
15
+ ## Architecture
16
+
17
+ **Orchestrate pipeline:** auth check -> body parse -> price resolve -> payment verify -> handler invoke -> settle -> finalize
18
+
19
+ - `src/orchestrate.ts` — Full request lifecycle orchestration
20
+ - `src/builder.ts` — Fluent RouteBuilder API
21
+ - `src/handler.ts` — Safe handler invocation with error mapping
22
+ - `src/types.ts` — Core types (RouteEntry, HandlerContext, HttpError)
23
+ - `src/registry.ts` — Route registry (Map-backed, silent overwrite on duplicate keys)
24
+ - `src/pricing.ts` — Price resolution (static, tiered, dynamic)
25
+ - `src/plugin.ts` — Plugin hook system
26
+ - `src/server.ts` — x402 server initialization
27
+ - `src/auth/` — Auth modules (siwx.ts, api-key.ts, nonce.ts)
28
+ - `src/protocols/` — Protocol handlers (x402.ts, mpp.ts, detect.ts)
29
+ - `src/discovery/` — Auto-generated endpoints (well-known.ts, openapi.ts)
30
+
31
+ ## Critical Rules
32
+
33
+ - **Error handling:** Respect `.status` on any thrown error, not just `HttpError`. The `Object.assign(new Error(), { status })` pattern is universal in Node.js.
34
+ - **SIWX challenge:** Must return a proper x402v2 challenge with `PAYMENT-REQUIRED` header and JSON body containing `extensions['sign-in-with-x']` with `domain`, `uri`, `version`, `chainId`, `type`, `nonce`, `issuedAt`.
35
+ - **Discovery:** `authMode !== 'unprotected'` determines well-known visibility, not the protocol list. SIWX routes return 402 challenges and must be discoverable.
36
+ - **OpenAPI:** Merge paths for multi-method endpoints (GET + DELETE on same path). Never overwrite.
37
+ - **Duplicate route keys:** Registry silently overwrites (last-write-wins) with a dev-only `console.warn`. This is intentional — Next.js module loading order is non-deterministic during `next build`, so discovery stubs and real handlers may register the same key in either order. Prior art: ElysiaJS uses the identical pattern. See stablestudio `.claude/13_route-registry-dedup.md` for full research.
38
+ - **Dynamic pricing (v0.3.1+):** Early body parsing with `request.clone()` enables accurate dynamic pricing. `maxPrice` is optional and acts as a safety net (cap + fallback). Body is parsed before 402 challenge generation when pricing function exists. See `.claude/15_router-dynamic-pricing-solution.md` for full design and derisking.
39
+
40
+ ## Environment Variables
41
+
42
+ The router uses the default facilitator from `@coinbase/x402`, which requires CDP API keys in `process.env`:
43
+
44
+ - `CDP_API_KEY_ID` — Coinbase Developer Platform API key ID
45
+ - `CDP_API_KEY_SECRET` — CDP API key secret
46
+
47
+ **Critical for Next.js apps with env validation (T3 stack, `@t3-oss/env-nextjs`):** These variables must be explicitly declared in your env schema. Next.js does not automatically expose all env vars to `process.env` — undeclared vars are invisible at runtime.
48
+
49
+ Without these keys, the default facilitator cannot authenticate with CDP:
50
+ - x402 server `initialize()` fails with "Failed to fetch supported kinds from facilitator: TypeError: fetch failed" or "Facilitator getSupported failed (401): Unauthorized"
51
+ - All payment routes return empty 402 responses (no `PAYMENT-REQUIRED` header, no body)
52
+
53
+ **Example env schema (T3/`@t3-oss/env-nextjs`):**
54
+
55
+ ```typescript
56
+ import { createEnv } from "@t3-oss/env-nextjs";
57
+ import { z } from "zod";
58
+
59
+ export const env = createEnv({
60
+ server: {
61
+ CDP_API_KEY_ID: z.string(),
62
+ CDP_API_KEY_SECRET: z.string(),
63
+ // ... other vars
64
+ },
65
+ runtimeEnv: {
66
+ CDP_API_KEY_ID: process.env.CDP_API_KEY_ID,
67
+ CDP_API_KEY_SECRET: process.env.CDP_API_KEY_SECRET,
68
+ // ... other vars
69
+ },
70
+ });
71
+ ```
72
+
73
+ **Alternative:** Pass a custom facilitator config to `createRouter()` if you want to use a different facilitator URL or auth mechanism. But for most apps, the default CDP facilitator is correct.
74
+
75
+ ## Version Stability
76
+
77
+ The public API is **not stable**. Downstream consumers should pin exact versions (`"@agentcash/router": "0.2.0"`, not `"^0.2.0"`). Breaking changes will happen as we build out multi-protocol support and discover patterns across services. Semver will be respected once we hit 1.0.
78
+
79
+ ## Build & Test
80
+
81
+ ```bash
82
+ pnpm build # tsup
83
+ pnpm test # vitest
84
+ pnpm typecheck # tsc --noEmit
85
+ pnpm check # format + lint + typecheck + build + test
86
+ ```
87
+
88
+ ## Development Record
89
+
90
+ The `.claude/` directory contains design docs, decision records, and bug analyses that document the reasoning behind the router's architecture. See `.claude/INDEX.md` for a table of contents.
91
+
92
+ **Convention:** Every doc has a `Status` header. When you resolve work described in a doc, update its Status to `Resolved in vX.Y.Z` and update INDEX.md.
@@ -0,0 +1,533 @@
1
+ ---
2
+ name: router-guide
3
+ description: Use when creating, modifying, or debugging routes with @agentcash/router. Covers route creation, auth modes, pricing, provider monitoring, discovery, plugins, and troubleshooting. Trigger phrases include "add a route", "create an endpoint", "set up discovery", "add provider monitoring", "configure the router".
4
+ ---
5
+
6
+ # @agentcash/router Guide
7
+
8
+ You are helping a developer build API routes using `@agentcash/router`. This package is a fluent route builder for Next.js App Router that handles x402 payments, MPP payments, SIWX authentication, API key auth, and provider monitoring.
9
+
10
+ ## Philosophy and Design Constraints
11
+
12
+ These are intentional decisions. Do not change them without explicit user approval.
13
+
14
+ ### Why this exists
15
+
16
+ Every paid API route in a Merit Systems service shared the same ~80-150 lines of boilerplate: x402 server init, payment verification, body parsing, settlement gating, error handling, observability hooks. Five services meant five copies that drifted. This router eliminates that by encoding the lifecycle into a fluent builder that compiles to a single Next.js handler function.
17
+
18
+ ### Core design principles
19
+
20
+ 1. **Fluent builder, not config object.** Progressive discoverability via IDE autocomplete. The chain reads like a sentence: `route('search').paid('0.01').body(schema).handler(fn)`. Config objects hide available options; fluent chains surface them.
21
+
22
+ 2. **Use `x402ResourceServer` primitives directly, not `withX402`.** The `withX402` wrapper from `@coinbase/x402` is a convenience that owns the full request lifecycle. We need to interleave body parsing, Zod validation, plugin hooks, and settlement gating at specific points in that lifecycle, so we call the lower-level primitives (`buildPaymentRequirementsFromOptions`, `verifyPayment`, `settlePayment`) directly.
23
+
24
+ 3. **Auth modes are mutually exclusive per route, except `apiKey` + `paid`.** A route is either paid, SIWX-authenticated, API-key-gated, or unprotected. The one exception: `.apiKey()` can compose with `.paid()` because some routes need both identity (API key) and payment (x402/MPP). This is enforced at the type level.
25
+
26
+ 4. **Body is only buffered when `.body()` is chained.** If a route doesn't declare a body schema, the request stream is untouched. This matters for routes that proxy multipart uploads or streams. For dynamic pricing, the body IS parsed before the 402 challenge (via `request.clone()`) so the pricing function can calculate an accurate price.
27
+
28
+ 5. **Settlement is gated on `response.status < 400`.** If the handler throws or returns an error response, no settlement occurs. The payer's funds are not captured. This is a fundamental safety guarantee.
29
+
30
+ 6. **The plugin interface is the observability boundary.** All Merit-specific telemetry (ClickHouse, Discord alerts, usage tracking) lives in a private `RouterPlugin` implementation. The router itself is fully open-source with zero Merit-specific code. The plugin hooks are fire-and-forget — they never delay the response.
31
+
32
+ 7. **Self-registering routes + validated barrel (Approach B).** Routes self-register via `.handler()` at import time. A barrel file imports all route modules. Discovery endpoints (`.wellKnown()`, `.openapi()`) validate that the barrel is complete by comparing registered routes against the `prices` map. For routes in separate handler files (e.g., Next.js `route.ts` files), use **discovery stubs** — lightweight registrations that provide metadata for discovery without the real handler. Guard stubs with `registry.has()` to avoid unnecessary overwrites.
33
+
34
+ 8. **Both x402 and MPP ship from day one.** Dual-protocol support is not an afterthought. Routes declare `protocols: ['x402', 'mpp']` and the orchestration layer routes to the correct handler based on the request header. MPP uses low-level `mpay` primitives (`Challenge`, `Credential`, `tempo.charge`) — not the high-level `Mpay.create()` wrapper — because the router owns orchestration.
35
+
36
+ 9. **`zod-openapi` for OpenAPI 3.1.** Zod schemas are the single source of truth for request/response types. OpenAPI docs are auto-generated from them. No manual spec maintenance.
37
+
38
+ 10. **Fakes over mocks in tests.** The test suite uses `FakeX402Server` (a behavioral fake that accepts known payer/payee/amount tuples) instead of vi.mock stubs. This tests real verification logic, not mock wiring.
39
+
40
+ ### Version Stability
41
+
42
+ The public API is **not stable**. Downstream consumers should pin exact versions (`"@agentcash/router": "0.2.0"`, not `"^0.2.0"`). Breaking changes will happen as we build out multi-protocol support and discover patterns across services. Semver will be respected once we hit 1.0.
43
+
44
+ ### Non-goals (deliberately rejected)
45
+
46
+ - **No middleware chain.** Express-style middleware (`use()`) was considered and rejected. The builder's fixed lifecycle (auth → parse → validate → price → verify → handler → settle) covers all routes. Custom logic goes in the handler or plugin.
47
+ - **No per-request config overrides.** The route is fully configured at definition time. Runtime behavior changes go through the handler function, not builder reconfiguration.
48
+ - **No automatic retry on settlement failure.** Settlement failures are logged as critical alerts. Retry logic belongs in the plugin implementation, not the router core.
49
+
50
+ ## Architecture Overview
51
+
52
+ ```
53
+ createRouter(config) → ServiceRouter
54
+ ├── .route(key) → RouteBuilder (fluent chain)
55
+ │ ├── Auth: .paid() | .siwx() | .apiKey() | .unprotected()
56
+ │ ├── Schema: .body(zod) | .query(zod) | .output(zod)
57
+ │ ├── Meta: .description() | .path() | .provider()
58
+ │ └── Terminal: .handler(fn) → Next.js handler
59
+ ├── .wellKnown() → /.well-known/x402 handler
60
+ ├── .openapi() → /openapi.json handler
61
+ └── .monitors() → MonitorEntry[] (for cron)
62
+ ```
63
+
64
+ ### Request lifecycle (orchestrate.ts)
65
+
66
+ ```
67
+ Request in
68
+ → await x402 server init
69
+ → plugin.onRequest() → PluginContext
70
+ → if unprotected: skip to handler
71
+ → if apiKey route: verify API key → 401 if invalid
72
+ → detectProtocol(request) from headers
73
+ → if no payment header + dynamic pricing + body schema:
74
+ early body parse via request.clone() for accurate 402 price
75
+ → if SIWX: challenge or verify → handleAuth
76
+ → if no auth header: build 402 challenge
77
+ (x402: PAYMENT-REQUIRED header, MPP: WWW-Authenticate header)
78
+ → bufferBody + validateBody (only if .body() chained)
79
+ → resolvePrice (static / dynamic / tiered)
80
+ → protocol verify (x402 or MPP)
81
+ → plugin.onPaymentVerified()
82
+ → handler(ctx) → result
83
+ → if status < 400: settle payment (x402) or receipt (MPP)
84
+ → if provider configured: fireProviderQuota()
85
+ → plugin.onResponse()
86
+ Response out
87
+ ```
88
+
89
+ ## Key Files
90
+
91
+ | File | Purpose |
92
+ |------|---------|
93
+ | `src/index.ts` | `createRouter()`, `ServiceRouter`, re-exports |
94
+ | `src/builder.ts` | `RouteBuilder` fluent API with type-level state tracking |
95
+ | `src/orchestrate.ts` | Core request lifecycle, `createRequestHandler()` |
96
+ | `src/types.ts` | `HttpError`, `HandlerContext`, pricing/provider/auth types |
97
+ | `src/plugin.ts` | `RouterPlugin` interface, `consolePlugin()`, `firePluginHook()` |
98
+ | `src/pricing.ts` | `resolvePrice()`, `resolveMaxPrice()` |
99
+ | `src/handler.ts` | `safeCallHandler()` error boundary |
100
+ | `src/body.ts` | `bufferBody()`, `validateBody()` |
101
+ | `src/registry.ts` | `RouteRegistry` with barrel validation |
102
+ | `src/protocols/detect.ts` | Header-based protocol detection |
103
+ | `src/protocols/x402.ts` | x402 challenge/verify/settle wrappers |
104
+ | `src/protocols/mpp.ts` | MPP challenge/verify/receipt wrappers (uses mpay low-level primitives) |
105
+ | `src/server.ts` | x402 server initialization with retry |
106
+ | `src/auth/siwx.ts` | SIWX verification |
107
+ | `src/auth/api-key.ts` | API key verification |
108
+ | `src/auth/nonce.ts` | `NonceStore` interface + `MemoryNonceStore` |
109
+ | `src/discovery/well-known.ts` | `.well-known/x402` generation |
110
+ | `src/discovery/openapi.ts` | OpenAPI 3.1 spec generation |
111
+
112
+ ## Environment Setup
113
+
114
+ **CRITICAL:** The router uses the default facilitator from `@coinbase/x402`, which requires CDP API keys at runtime:
115
+
116
+ ```bash
117
+ CDP_API_KEY_ID=your-key-id
118
+ CDP_API_KEY_SECRET=your-key-secret
119
+ ```
120
+
121
+ **For Next.js apps with env validation (T3 stack, `@t3-oss/env-nextjs`):** These must be declared in your env schema. Next.js does not expose undeclared env vars to `process.env`.
122
+
123
+ Without these keys, x402 server initialization fails:
124
+ - Error: `"Failed to fetch supported kinds from facilitator: TypeError: fetch failed"`
125
+ - Or: `"Facilitator getSupported failed (401): Unauthorized"`
126
+ - Symptom: All paid routes return empty 402 responses (no `PAYMENT-REQUIRED` header)
127
+
128
+ **Example env schema (T3):**
129
+
130
+ ```typescript
131
+ // src/env.js
132
+ import { createEnv } from "@t3-oss/env-nextjs";
133
+ import { z } from "zod";
134
+
135
+ export const env = createEnv({
136
+ server: {
137
+ CDP_API_KEY_ID: z.string(),
138
+ CDP_API_KEY_SECRET: z.string(),
139
+ // ... other vars
140
+ },
141
+ runtimeEnv: {
142
+ CDP_API_KEY_ID: process.env.CDP_API_KEY_ID,
143
+ CDP_API_KEY_SECRET: process.env.CDP_API_KEY_SECRET,
144
+ // ... other vars
145
+ },
146
+ });
147
+ ```
148
+
149
+ **Why this matters:** The default facilitator reads `process.env.CDP_API_KEY_ID` and `process.env.CDP_API_KEY_SECRET` when creating auth headers for the CDP facilitator API. If they're not in `process.env`, authentication fails silently — the facilitator sends requests without `Authorization` headers, and CDP returns 401.
150
+
151
+ ### MPP environment
152
+
153
+ For MPP (Micropayment Protocol via Tempo blockchain), you need:
154
+
155
+ ```bash
156
+ MPP_SECRET_KEY=your-hmac-secret # HMAC key for challenge binding
157
+ TEMPO_RPC_URL=https://user:pass@rpc.mainnet.tempo.xyz # Authenticated Tempo RPC
158
+ ```
159
+
160
+ **Tempo RPC requires authentication.** The default `rpc.tempo.xyz` returns 401. Get credentials from the Tempo team. The `rpcUrl` can be set in config or via `TEMPO_RPC_URL` env var (config takes precedence).
161
+
162
+ **Peer dependencies for MPP:** `mpay` is an optional peer dep. When installed, it brings `viem` as a transitive dependency. Both are required for MPP support.
163
+
164
+ ## Creating Routes
165
+
166
+ ### Step 1: Router setup (once per service)
167
+
168
+ ```typescript
169
+ // lib/routes.ts
170
+ import { createRouter } from '@agentcash/router';
171
+
172
+ export const router = createRouter({
173
+ payeeAddress: process.env.X402_PAYEE_ADDRESS!,
174
+ // Optional:
175
+ network: 'eip155:8453', // default
176
+ protocols: ['x402', 'mpp'], // protocols for auto-priced routes (default: ['x402'])
177
+ plugin: myPlugin, // observability
178
+ prices: { 'search': '0.02' }, // central pricing map
179
+ mpp: { // MPP support (requires mpay peer dep)
180
+ secretKey: process.env.MPP_SECRET_KEY!,
181
+ currency: '0x20c0000000000000000000000000000000000000', // PathUSD on Tempo
182
+ recipient: process.env.X402_PAYEE_ADDRESS!,
183
+ rpcUrl: process.env.TEMPO_RPC_URL, // falls back to TEMPO_RPC_URL env var
184
+ },
185
+ siwx: { nonceStore }, // custom nonce store
186
+ });
187
+ ```
188
+
189
+ **Router-level `protocols`:** Sets default protocols for routes using the `prices` map (auto-priced routes). Routes using `.paid()` directly can override with `{ protocols: [...] }`.
190
+
191
+ ### Step 2: Define route files
192
+
193
+ Each route file exports a Next.js handler (`GET`, `POST`, etc.):
194
+
195
+ ```typescript
196
+ // app/api/search/route.ts
197
+ import { router } from '@/lib/routes';
198
+ import { searchSchema, searchResponseSchema } from '@/lib/schemas';
199
+
200
+ export const POST = router.route('search')
201
+ .paid('0.01')
202
+ .body(searchSchema)
203
+ .output(searchResponseSchema)
204
+ .description('Search the web')
205
+ .handler(async ({ body }) => searchService(body));
206
+ ```
207
+
208
+ ### Step 3: Barrel import
209
+
210
+ ```typescript
211
+ // lib/routes/barrel.ts
212
+ import '@/app/api/search/route';
213
+ import '@/app/api/lookup/route';
214
+ // ... all route files
215
+ ```
216
+
217
+ The barrel ensures all routes are registered before discovery endpoints generate their output.
218
+
219
+ ## Auth Modes
220
+
221
+ ### Paid (x402) — static price
222
+ ```typescript
223
+ router.route('search').paid('0.01').body(schema).handler(fn);
224
+ ```
225
+
226
+ ### Paid (dynamic pricing)
227
+ ```typescript
228
+ router.route('gen')
229
+ .paid((body) => calculateCost(body), { maxPrice: '5.00' })
230
+ .body(schema)
231
+ .handler(fn);
232
+ ```
233
+ **How it works:** When a request arrives without a payment header, the router clones the request (`request.clone()`), parses the body early, and calls the pricing function to calculate an accurate price for the 402 challenge. This means clients see the real price, not a ceiling.
234
+
235
+ `maxPrice` is **optional** and acts as a safety net:
236
+ - **Cap:** If the pricing function returns a value above `maxPrice`, the price is capped and a warning is fired via the plugin.
237
+ - **Fallback:** If the pricing function throws, `maxPrice` is used as a degraded-mode fallback. Without `maxPrice`, the route returns 500.
238
+ - If omitted and the pricing function succeeds, the exact calculated price is used.
239
+
240
+ ### Paid (tiered) — requires body
241
+ ```typescript
242
+ router.route('upload').paid({
243
+ field: 'tier',
244
+ tiers: {
245
+ '10mb': { price: '0.02', label: '10 MB' },
246
+ '100mb': { price: '0.20', label: '100 MB' },
247
+ },
248
+ }).body(schema).handler(fn);
249
+ ```
250
+ The tier value comes from `body[field]`. The 402 challenge uses the highest tier price.
251
+
252
+ ### Dual protocol (x402 + MPP)
253
+ ```typescript
254
+ router.route('search')
255
+ .paid('0.01', { protocols: ['x402', 'mpp'] })
256
+ .body(schema).handler(fn);
257
+ ```
258
+
259
+ ### SIWX (wallet auth, no payment)
260
+ ```typescript
261
+ router.route('inbox/status')
262
+ .siwx()
263
+ .query(querySchema)
264
+ .handler(async ({ query, wallet }) => getStatus(query, wallet));
265
+ ```
266
+
267
+ ### API key (composable with paid)
268
+ ```typescript
269
+ router.route('admin/lookup')
270
+ .apiKey((key) => key === 'valid' ? { id: 'acct-1' } : null)
271
+ .paid('0.05')
272
+ .body(schema)
273
+ .handler(async ({ body, account }) => lookup(body, account));
274
+ ```
275
+ API key is checked first (401 on failure), then payment flow runs.
276
+
277
+ ### Unprotected
278
+ ```typescript
279
+ router.route('health').unprotected().handler(async () => ({ status: 'ok' }));
280
+ ```
281
+
282
+ ## Handler Context
283
+
284
+ Every handler receives:
285
+
286
+ ```typescript
287
+ interface HandlerContext<TBody, TQuery> {
288
+ body: TBody; // Parsed + validated (undefined if no .body())
289
+ query: TQuery; // Parsed + validated (undefined if no .query())
290
+ request: NextRequest; // Raw request
291
+ requestId: string; // Unique per-request UUID (for logging/tracing)
292
+ route: string; // Route key (e.g. 'search', 'lookup/org')
293
+ wallet: string | null; // Verified wallet (from payment or SIWX)
294
+ account: unknown; // From .apiKey() resolver
295
+ alert: AlertFn; // Fire observability alerts
296
+ setVerifiedWallet: (addr: string) => void;
297
+ }
298
+ ```
299
+
300
+ ## Provider Monitoring
301
+
302
+ Routes that wrap third-party APIs can declare monitoring behavior per-provider. This surfaces quota/balance information through the plugin system.
303
+
304
+ ### The six provider patterns
305
+
306
+ | Pattern | How handled |
307
+ |---------|-------------|
308
+ | Balance in response headers | `extractQuota` reads `headers` |
309
+ | Balance in response body | `extractQuota` reads `result` |
310
+ | Separate health-check endpoint | `monitor` function (for cron) |
311
+ | Overages at same rate | `overage: 'same-rate'` |
312
+ | Overages at increased rate | `overage: 'increased-rate'` |
313
+ | No overages, hard stop | `overage: 'hard-stop'` |
314
+
315
+ ### Usage
316
+
317
+ ```typescript
318
+ export const POST = router.route('exa/search')
319
+ .paid('0.01')
320
+ .provider('exa', {
321
+ extractQuota: (result, headers) => ({
322
+ remaining: (result as any).rateLimit?.remaining ?? null,
323
+ limit: (result as any).rateLimit?.limit ?? null,
324
+ }),
325
+ monitor: async () => {
326
+ const res = await fetch('https://api.exa.ai/usage');
327
+ const data = await res.json();
328
+ return { remaining: data.credits, limit: data.limit };
329
+ },
330
+ overage: 'same-rate',
331
+ warn: 100,
332
+ critical: 10,
333
+ })
334
+ .body(searchSchema)
335
+ .handler(async ({ body }) => exaClient.search(body));
336
+ ```
337
+
338
+ ### ProviderConfig fields
339
+
340
+ | Field | Type | Default | Description |
341
+ |-------|------|---------|-------------|
342
+ | `extractQuota` | `(result, headers) => QuotaInfo \| null` | — | Inline extraction after each success |
343
+ | `monitor` | `() => Promise<QuotaInfo \| null>` | — | Standalone check for cron |
344
+ | `overage` | `'same-rate' \| 'increased-rate' \| 'hard-stop'` | `'same-rate'` | What happens at zero |
345
+ | `warn` | `number` | — | Warn level threshold |
346
+ | `critical` | `number` | — | Critical level threshold |
347
+
348
+ ### Threshold logic
349
+
350
+ - `remaining === null` → `healthy` (no data)
351
+ - `remaining <= critical` → `critical`
352
+ - `remaining <= warn` → `warn`
353
+ - Otherwise → `healthy`
354
+
355
+ ### Cron monitors
356
+
357
+ ```typescript
358
+ for (const entry of router.monitors()) {
359
+ const quota = await entry.monitor();
360
+ // entry: { provider, route, monitor, overage, warn, critical }
361
+ }
362
+ ```
363
+
364
+ ### Safety guarantees
365
+
366
+ - `extractQuota` is fire-and-forget — exceptions never affect the response
367
+ - Only runs when `response.status < 400`
368
+ - Plugin hook (`onProviderQuota`) is non-blocking
369
+
370
+ ## Discovery Setup
371
+
372
+ ```typescript
373
+ // app/.well-known/x402/route.ts
374
+ import '@/lib/routes/barrel'; // barrel import FIRST to register all routes
375
+ import { router } from '@/lib/routes';
376
+ export const GET = router.wellKnown();
377
+
378
+ // app/openapi.json/route.ts
379
+ import '@/lib/routes/barrel';
380
+ import { router } from '@/lib/routes';
381
+ export const GET = router.openapi({ title: 'My API', version: '1.0.0' });
382
+ ```
383
+
384
+ **Barrel import must come first.** Without it, Next.js lazy-loads route modules, so discovery endpoints hit before routes register → `route 'X' in prices map but not registered` error.
385
+
386
+ **`.well-known/x402` output** includes `mppResources` alongside `resources` when MPP routes exist:
387
+ ```json
388
+ { "version": 1, "resources": ["..."], "mppResources": ["..."] }
389
+ ```
390
+
391
+ **OpenAPI spec** includes `x-payment-info` with `price` and `protocols` per operation.
392
+
393
+ ## Plugin (Observability)
394
+
395
+ ```typescript
396
+ const myPlugin: RouterPlugin = {
397
+ onRequest(meta) { /* return PluginContext */ },
398
+ onPaymentVerified(ctx, payment) { /* log payment */ },
399
+ onPaymentSettled(ctx, settlement) { /* log tx */ },
400
+ onResponse(ctx, response) { /* log response */ },
401
+ onError(ctx, error) { /* alert on errors */ },
402
+ onAlert(ctx, alert) { /* handle custom alerts */ },
403
+ onProviderQuota(ctx, event) { /* handle quota events */ },
404
+ };
405
+ ```
406
+
407
+ All hooks are optional. All are fire-and-forget. Use `consolePlugin()` for dev logging.
408
+
409
+ ## Builder Compile-Time Safety
410
+
411
+ The type system (generic parameters `HasAuth`, `NeedsBody`, `HasBody`) prevents invalid chains:
412
+
413
+ - `.handler()` requires auth to be set first
414
+ - Dynamic/tiered pricing requires `.body()` before `.handler()`
415
+ - `.siwx()` is mutually exclusive with `.paid()`
416
+ - `.apiKey()` CAN compose with `.paid()`
417
+
418
+ ## MPP Internals (Critical Pitfalls)
419
+
420
+ The router uses mpay's **low-level primitives**, not the high-level `Mpay.create()` API. This matters because mpay's internals have subtle conventions:
421
+
422
+ 1. **NextRequest vs Request.** `Credential.fromRequest()` breaks with Next.js `NextRequest` due to subtle header handling differences. The router converts via `toStandardRequest()` — creating a new standard `Request` with the same URL, method, headers, and body.
423
+
424
+ 2. **`Challenge.fromIntent()` takes payment data, not an HTTP Request.** The `request` field in `fromIntent()` is the payment request object (`{ amount, currency, recipient, decimals }`), NOT the HTTP Request. Passing the wrong object causes silent challenge generation failures.
425
+
426
+ 3. **`tempo.charge().verify()` returns a receipt, not `{ valid, payer }`.** On success it returns `{ method, status, reference, timestamp }`. On failure it throws. Check `receipt.status === 'success'`, not `receipt.valid`.
427
+
428
+ 4. **`getClient` must be synchronous.** mpay's `Client.getResolver()` checks `if (getClient) return getClient` — it does NOT await. An async `getClient` will silently fall through to the default RPC URL.
429
+
430
+ 5. **Tempo RPC requires authentication.** The default `rpc.tempo.xyz` returns 401. Always provide `rpcUrl` or set `TEMPO_RPC_URL` env var with authenticated credentials.
431
+
432
+ 6. **viem is loaded eagerly in `ensureMpay()`.** Since `getClient` must be synchronous, viem's `createClient` and `http` are loaded once when mpay initializes, not per-call. viem is a transitive dep of mpay and is always available when mpay is installed.
433
+
434
+ ## Registration-Time Validation
435
+
436
+ These throw immediately when the route is defined (not at request time):
437
+
438
+ - Empty tier key in tiered pricing
439
+ - `maxPrice` that isn't a positive decimal
440
+
441
+ **Duplicate route keys** do NOT throw — the registry silently overwrites with a dev-only `console.warn`. This is intentional: Next.js `next build` loads modules non-deterministically, so discovery stubs and real handlers may register the same key in either order. Last writer wins. Prior art: ElysiaJS uses the identical pattern.
442
+
443
+ ## Central Pricing Map
444
+
445
+ ```typescript
446
+ const router = createRouter({
447
+ payeeAddress: '...',
448
+ protocols: ['x402', 'mpp'], // default protocols for all auto-priced routes
449
+ prices: { 'search': '0.02', 'lookup': '0.05' },
450
+ mpp: { secretKey, currency, recipient, rpcUrl },
451
+ });
452
+
453
+ // .paid() is auto-applied with router-level protocols:
454
+ export const POST = router.route('search').body(schema).handler(fn);
455
+ ```
456
+
457
+ Routes using `prices` inherit the router-level `protocols` config. Routes using `.paid()` directly can override per-route with `{ protocols: [...] }`.
458
+
459
+ Barrel validation catches mismatches: keys in `prices` but not registered → error.
460
+
461
+ ## Common Patterns
462
+
463
+ ### Return an error from handler
464
+ ```typescript
465
+ .handler(async ({ body }) => {
466
+ if (!valid(body)) throw new HttpError('Invalid input', 400);
467
+ return result;
468
+ });
469
+ ```
470
+
471
+ ### Return a raw Response (streaming)
472
+ ```typescript
473
+ .handler(async ({ body }) => {
474
+ return new Response(streamData, {
475
+ headers: { 'Content-Type': 'text/event-stream' },
476
+ });
477
+ });
478
+ ```
479
+
480
+ ### Fire a custom alert
481
+ ```typescript
482
+ .handler(async ({ body, alert }) => {
483
+ const result = await callProvider(body);
484
+ if (result.slow) alert('warn', 'Slow response', { latency: result.latency });
485
+ return result;
486
+ });
487
+ ```
488
+
489
+ ## Troubleshooting
490
+
491
+ | Issue | Cause | Fix |
492
+ |-------|-------|-----|
493
+ | `route 'X' registered twice` warning | Discovery stub + real handler both register same key | Expected during `next build` — last writer wins. Use `registry.has()` guards on stubs to suppress. |
494
+ | `x402 server not initialized` | Missing peer deps | Install `@x402/core @x402/evm @x402/extensions @coinbase/x402` |
495
+ | 402 on every request | No payment header | Client must send `PAYMENT-SIGNATURE` (x402) or `Authorization: Payment` (MPP) |
496
+ | Body undefined in handler | No `.body()` chained | Add `.body(schema)` to the chain |
497
+ | Route not in discovery docs | Missing barrel import | Import the route file in your barrel |
498
+ | Settlement not happening | Handler returned status >= 400 | Settlement is gated on success responses |
499
+ | MPP 401 `unauthorized: authentication required` | Using default unauthenticated Tempo RPC | Set `TEMPO_RPC_URL` env var or `mpp.rpcUrl` config with authenticated URL |
500
+ | MPP `Credential.fromRequest()` returns undefined | NextRequest header handling incompatibility | Router handles this via `toStandardRequest()` — if you see this, the router dist is stale |
501
+ | MPP verify returns `status: 'success'` but route returns 402 | Code checking `.valid` instead of `.status` | Verify returns a receipt `{ status, reference }`, not `{ valid, payer }` |
502
+ | MPP using wrong RPC URL after rebuild | Next.js webpack cache or stale pnpm link | Delete `.next/`, run `pnpm install` in the app to pick up new router dist |
503
+ | `route 'X' in prices map but not registered` | Discovery endpoint hit before route module loaded | Add barrel import to discovery route files |
504
+ | `mpay package is required` | mpay not installed | `pnpm add mpay` — it's an optional peer dep |
505
+
506
+ ## Maintaining This Skill
507
+
508
+ This skill file is the canonical reference for `@agentcash/router`. It ships with the repo at `.claude/skills/router-guide/SKILL.md` so that any agent working with the router has accurate, up-to-date guidance.
509
+
510
+ **When you modify the router, update this skill.** Specifically:
511
+
512
+ - **New interfaces or types** — Add them to the relevant section (Handler Context, Plugin, Provider Monitoring, etc.)
513
+ - **New builder methods** — Document in the Auth Modes or Common Patterns sections
514
+ - **New plugin hooks** — Add to the Plugin section
515
+ - **Changed behavior** — Update the Request Lifecycle diagram and any affected sections
516
+ - **New troubleshooting entries** — Add to the Troubleshooting table
517
+ - **New design constraints or non-goals** — Add to the Philosophy section
518
+
519
+ The goal is that any future agent can read this single file and implement correctly without needing to reverse-engineer the source. Keep it accurate, keep it concise.
520
+
521
+ ## Porting Services to the Router
522
+
523
+ When porting an existing service to `@agentcash/router`, the service's route patterns may not cleanly map to the router's abstractions. **If you encounter a pattern that the router doesn't support, do not work around it silently.** Instead:
524
+
525
+ 1. **Inform the user** — explain what the service does, what the router expects, and where the mismatch is.
526
+ 2. **Suggest a router PR** — if the gap is a reasonable feature (not a one-off hack), propose adding it to the router. Describe the change, which files it touches, and why it's general-purpose.
527
+ 3. **Let the user decide** — they may prefer a workaround, a router change, or restructuring the service.
528
+
529
+ Common conformance gaps to watch for:
530
+ - Handler that needs raw request body (not JSON) — router buffers as JSON when `.body()` is chained. Consider if the route should skip `.body()` and parse from `ctx.request` directly.
531
+ - Multiple pricing strategies on one route — router supports static, dynamic, or tiered, but only one per route.
532
+ - Auth mode that doesn't fit the 4 modes — router enforces paid/siwx/apiKey/unprotected. Composite auth beyond apiKey+paid is not supported.
533
+ - Custom response headers set by middleware — router owns the response lifecycle. Headers go in the handler return or via the plugin.
package/dist/index.cjs CHANGED
@@ -120,7 +120,7 @@ var RouteRegistry = class {
120
120
  };
121
121
 
122
122
  // src/orchestrate.ts
123
- var import_server2 = require("next/server");
123
+ var import_server3 = require("next/server");
124
124
 
125
125
  // src/plugin.ts
126
126
  function createDefaultContext(meta) {
@@ -354,70 +354,110 @@ async function settleX402Payment(server, payload, requirements) {
354
354
  }
355
355
 
356
356
  // src/protocols/mpp.ts
357
- var mpayLoaded = false;
358
- var Challenge;
359
- var Credential;
360
- var Receipt;
361
- var tempo;
362
- async function ensureMpay() {
363
- if (mpayLoaded) return;
364
- try {
365
- const mpay = await import("mpay");
366
- Challenge = mpay.Challenge;
367
- Credential = mpay.Credential;
368
- Receipt = mpay.Receipt;
369
- const mpayServer = await import("mpay/server");
370
- tempo = mpayServer.tempo;
371
- mpayLoaded = true;
372
- } catch {
373
- throw new Error("mpay package is required for MPP protocol support. Install it: pnpm add mpay");
357
+ var import_mpay = require("mpay");
358
+ var import_server2 = require("mpay/server");
359
+ var import_viem = require("viem");
360
+ var import_chains = require("viem/chains");
361
+ function buildGetClient(rpcUrl) {
362
+ const url = rpcUrl ?? process.env.TEMPO_RPC_URL;
363
+ if (!url) return {};
364
+ return {
365
+ getClient: () => (0, import_viem.createClient)({ chain: import_chains.tempo, transport: (0, import_viem.http)(url) })
366
+ };
367
+ }
368
+ function toStandardRequest(request) {
369
+ if (request.constructor.name === "Request") {
370
+ return request;
374
371
  }
372
+ return new Request(request.url, {
373
+ method: request.method,
374
+ headers: request.headers
375
+ });
375
376
  }
377
+ var DEFAULT_DECIMALS = 6;
376
378
  async function buildMPPChallenge(routeEntry, request, mppConfig, price) {
377
- await ensureMpay();
378
- const methodIntent = tempo.charge({
379
- amount: price,
380
- currency: mppConfig.currency,
381
- recipient: mppConfig.recipient ?? ""
379
+ const standardRequest = toStandardRequest(request);
380
+ const currency = mppConfig.currency;
381
+ const recipient = mppConfig.recipient ?? "";
382
+ const methodIntent = import_server2.tempo.charge({
383
+ currency,
384
+ recipient
382
385
  });
383
- const challenge = Challenge.fromIntent(methodIntent, {
386
+ const challenge = import_mpay.Challenge.fromIntent(methodIntent, {
384
387
  secretKey: mppConfig.secretKey,
385
- realm: new URL(request.url).origin,
386
- request
388
+ realm: new URL(standardRequest.url).origin,
389
+ request: {
390
+ amount: price,
391
+ currency,
392
+ recipient,
393
+ decimals: DEFAULT_DECIMALS
394
+ }
387
395
  });
388
- return Challenge.serialize(challenge);
396
+ return import_mpay.Challenge.serialize(challenge);
389
397
  }
390
398
  async function verifyMPPCredential(request, _routeEntry, mppConfig, price) {
391
- await ensureMpay();
392
- const credential = Credential.fromRequest(request);
393
- if (!credential) return null;
394
- const isValid = Challenge.verify(credential.challenge, { secretKey: mppConfig.secretKey });
395
- if (!isValid) {
396
- return { valid: false, payer: null };
397
- }
398
- const chargeConfig = {
399
- amount: price,
400
- currency: mppConfig.currency,
401
- recipient: mppConfig.recipient ?? ""
402
- };
403
- const verifyResult = await tempo.charge(chargeConfig).verify(credential);
404
- if (!verifyResult?.valid) {
405
- return { valid: false, payer: null };
399
+ const standardRequest = toStandardRequest(request);
400
+ const currency = mppConfig.currency;
401
+ const recipient = mppConfig.recipient ?? "";
402
+ try {
403
+ const authHeader = standardRequest.headers.get("Authorization");
404
+ if (!authHeader) {
405
+ console.error("[MPP] No Authorization header found");
406
+ return null;
407
+ }
408
+ const credential = import_mpay.Credential.fromRequest(standardRequest);
409
+ if (!credential?.challenge) {
410
+ console.error("[MPP] Invalid credential structure");
411
+ return null;
412
+ }
413
+ const isValid = import_mpay.Challenge.verify(credential.challenge, { secretKey: mppConfig.secretKey });
414
+ if (!isValid) {
415
+ console.error("[MPP] Challenge HMAC verification failed");
416
+ return { valid: false, payer: null };
417
+ }
418
+ const methodIntent = import_server2.tempo.charge({
419
+ currency,
420
+ recipient,
421
+ ...buildGetClient(mppConfig.rpcUrl)
422
+ });
423
+ const paymentRequest = {
424
+ amount: price,
425
+ currency,
426
+ recipient,
427
+ decimals: DEFAULT_DECIMALS
428
+ };
429
+ const resolvedRequest = methodIntent.request ? await methodIntent.request({ credential, request: paymentRequest }) : paymentRequest;
430
+ const receipt = await methodIntent.verify({
431
+ credential,
432
+ request: resolvedRequest
433
+ });
434
+ if (!receipt || receipt.status !== "success") {
435
+ console.error("[MPP] Tempo verification failed:", receipt);
436
+ return { valid: false, payer: null };
437
+ }
438
+ const payer = receipt.reference ?? "";
439
+ return {
440
+ valid: true,
441
+ payer,
442
+ txHash: receipt.reference
443
+ };
444
+ } catch (error) {
445
+ console.error("[MPP] Credential verification error:", {
446
+ message: error instanceof Error ? error.message : String(error),
447
+ stack: error instanceof Error ? error.stack : void 0,
448
+ errorType: error?.constructor?.name
449
+ });
450
+ return null;
406
451
  }
407
- return {
408
- valid: true,
409
- payer: verifyResult.payer
410
- };
411
452
  }
412
- async function buildMPPReceipt(reference) {
413
- await ensureMpay();
414
- const receipt = Receipt.from({
453
+ function buildMPPReceipt(reference) {
454
+ const receipt = import_mpay.Receipt.from({
415
455
  method: "tempo",
416
456
  status: "success",
417
457
  reference,
418
458
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
419
459
  });
420
- return Receipt.serialize(receipt);
460
+ return import_mpay.Receipt.serialize(receipt);
421
461
  }
422
462
 
423
463
  // src/auth/siwx.ts
@@ -495,7 +535,7 @@ function createRequestHandler(routeEntry, handler, deps) {
495
535
  firePluginResponse(deps, pluginCtx, meta, response);
496
536
  }
497
537
  function fail(status, message, meta, pluginCtx) {
498
- const response = import_server2.NextResponse.json({ success: false, error: message }, { status });
538
+ const response = import_server3.NextResponse.json({ success: false, error: message }, { status });
499
539
  firePluginResponse(deps, pluginCtx, meta, response);
500
540
  return response;
501
541
  }
@@ -595,7 +635,7 @@ function createRequestHandler(routeEntry, handler, deps) {
595
635
  route: routeEntry.key
596
636
  });
597
637
  }
598
- const response = new import_server2.NextResponse(JSON.stringify(paymentRequired), {
638
+ const response = new import_server3.NextResponse(JSON.stringify(paymentRequired), {
599
639
  status: 402,
600
640
  headers: { "Content-Type": "application/json" }
601
641
  });
@@ -727,7 +767,7 @@ async function parseBody(request, routeEntry) {
727
767
  if (result.success) return { ok: true, data: result.data };
728
768
  return {
729
769
  ok: false,
730
- response: import_server2.NextResponse.json(
770
+ response: import_server3.NextResponse.json(
731
771
  { success: false, error: result.error, issues: result.issues },
732
772
  { status: 400 }
733
773
  )
@@ -786,7 +826,7 @@ async function resolveDynamicPrice(bodyData, routeEntry, deps, pluginCtx, meta)
786
826
  });
787
827
  return { price: routeEntry.maxPrice };
788
828
  } else {
789
- const errorResponse = import_server2.NextResponse.json(
829
+ const errorResponse = import_server3.NextResponse.json(
790
830
  { success: false, error: "Price calculation failed" },
791
831
  { status: 500 }
792
832
  );
@@ -796,7 +836,12 @@ async function resolveDynamicPrice(bodyData, routeEntry, deps, pluginCtx, meta)
796
836
  }
797
837
  }
798
838
  async function build402(request, routeEntry, deps, meta, pluginCtx, bodyData) {
799
- const response = new import_server2.NextResponse(null, { status: 402 });
839
+ const response = new import_server3.NextResponse(null, {
840
+ status: 402,
841
+ headers: {
842
+ "Content-Type": "application/json"
843
+ }
844
+ });
800
845
  let challengePrice;
801
846
  if (bodyData !== void 0 && typeof routeEntry.pricing === "function") {
802
847
  const result = await resolveDynamicPrice(bodyData, routeEntry, deps, pluginCtx, meta);
@@ -1087,7 +1132,7 @@ var MemoryNonceStore = class {
1087
1132
  };
1088
1133
 
1089
1134
  // src/discovery/well-known.ts
1090
- var import_server3 = require("next/server");
1135
+ var import_server4 = require("next/server");
1091
1136
  function createWellKnownHandler(registry, baseUrl, pricesKeys, options = {}) {
1092
1137
  let validated = false;
1093
1138
  return async (_request) => {
@@ -1125,7 +1170,7 @@ function createWellKnownHandler(registry, baseUrl, pricesKeys, options = {}) {
1125
1170
  if (instructions) {
1126
1171
  body.instructions = instructions;
1127
1172
  }
1128
- return import_server3.NextResponse.json(body, {
1173
+ return import_server4.NextResponse.json(body, {
1129
1174
  headers: {
1130
1175
  "Access-Control-Allow-Origin": "*",
1131
1176
  "Access-Control-Allow-Methods": "GET",
@@ -1136,12 +1181,12 @@ function createWellKnownHandler(registry, baseUrl, pricesKeys, options = {}) {
1136
1181
  }
1137
1182
 
1138
1183
  // src/discovery/openapi.ts
1139
- var import_server4 = require("next/server");
1184
+ var import_server5 = require("next/server");
1140
1185
  function createOpenAPIHandler(registry, baseUrl, pricesKeys, options) {
1141
1186
  let cached = null;
1142
1187
  let validated = false;
1143
1188
  return async (_request) => {
1144
- if (cached) return import_server4.NextResponse.json(cached);
1189
+ if (cached) return import_server5.NextResponse.json(cached);
1145
1190
  if (!validated && pricesKeys) {
1146
1191
  registry.validate(pricesKeys);
1147
1192
  validated = true;
@@ -1168,7 +1213,7 @@ function createOpenAPIHandler(registry, baseUrl, pricesKeys, options) {
1168
1213
  tags: Array.from(tagSet).sort().map((name) => ({ name })),
1169
1214
  paths
1170
1215
  });
1171
- return import_server4.NextResponse.json(cached);
1216
+ return import_server5.NextResponse.json(cached);
1172
1217
  };
1173
1218
  }
1174
1219
  function deriveTag(routeKey) {
package/dist/index.d.cts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { ZodType } from 'zod';
3
+ import { PaymentRequirements, PaymentRequired, SettleResponse } from '@x402/core/types';
3
4
 
4
5
  interface NonceStore {
5
6
  check(nonce: string): Promise<boolean>;
@@ -82,6 +83,7 @@ interface AlertEvent {
82
83
  meta?: Record<string, unknown>;
83
84
  }
84
85
  type AlertFn = (level: AlertLevel, message: string, meta?: Record<string, unknown>) => void;
86
+
85
87
  interface X402Server {
86
88
  initialize(): Promise<void>;
87
89
  buildPaymentRequirementsFromOptions(options: Array<{
@@ -91,18 +93,18 @@ interface X402Server {
91
93
  payTo: string;
92
94
  }>, context: {
93
95
  request: Request;
94
- }): Promise<unknown[]>;
95
- createPaymentRequiredResponse(requirements: unknown[], resource: {
96
+ }): Promise<PaymentRequirements[]>;
97
+ createPaymentRequiredResponse(requirements: PaymentRequirements[], resource: {
96
98
  url: string;
97
99
  method: string;
98
100
  description?: string;
99
- }, error: string | null, extensions?: Record<string, unknown>): Promise<unknown>;
100
- findMatchingRequirements(requirements: unknown[], payload: unknown): unknown;
101
- verifyPayment(payload: unknown, requirements: unknown): Promise<{
101
+ }, error: string | null, extensions?: Record<string, unknown>): Promise<PaymentRequired>;
102
+ findMatchingRequirements(requirements: PaymentRequirements[], payload: unknown): PaymentRequirements;
103
+ verifyPayment(payload: unknown, requirements: PaymentRequirements): Promise<{
102
104
  isValid: boolean;
103
105
  payer?: string;
104
106
  }>;
105
- settlePayment(payload: unknown, requirements: unknown): Promise<unknown>;
107
+ settlePayment(payload: unknown, requirements: PaymentRequirements): Promise<SettleResponse>;
106
108
  }
107
109
  type ProtocolType = 'x402' | 'mpp';
108
110
  type AuthMode = 'paid' | 'siwx' | 'apiKey' | 'unprotected';
@@ -183,6 +185,8 @@ interface RouterConfig {
183
185
  secretKey: string;
184
186
  currency: string;
185
187
  recipient?: string;
188
+ /** Tempo RPC URL for on-chain verification. Falls back to TEMPO_RPC_URL env var. */
189
+ rpcUrl?: string;
186
190
  };
187
191
  /**
188
192
  * Payment protocols to accept on auto-priced routes (those using the `prices` config).
@@ -239,6 +243,7 @@ interface OrchestrateDeps {
239
243
  secretKey: string;
240
244
  currency: string;
241
245
  recipient?: string;
246
+ rpcUrl?: string;
242
247
  };
243
248
  }
244
249
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { ZodType } from 'zod';
3
+ import { PaymentRequirements, PaymentRequired, SettleResponse } from '@x402/core/types';
3
4
 
4
5
  interface NonceStore {
5
6
  check(nonce: string): Promise<boolean>;
@@ -82,6 +83,7 @@ interface AlertEvent {
82
83
  meta?: Record<string, unknown>;
83
84
  }
84
85
  type AlertFn = (level: AlertLevel, message: string, meta?: Record<string, unknown>) => void;
86
+
85
87
  interface X402Server {
86
88
  initialize(): Promise<void>;
87
89
  buildPaymentRequirementsFromOptions(options: Array<{
@@ -91,18 +93,18 @@ interface X402Server {
91
93
  payTo: string;
92
94
  }>, context: {
93
95
  request: Request;
94
- }): Promise<unknown[]>;
95
- createPaymentRequiredResponse(requirements: unknown[], resource: {
96
+ }): Promise<PaymentRequirements[]>;
97
+ createPaymentRequiredResponse(requirements: PaymentRequirements[], resource: {
96
98
  url: string;
97
99
  method: string;
98
100
  description?: string;
99
- }, error: string | null, extensions?: Record<string, unknown>): Promise<unknown>;
100
- findMatchingRequirements(requirements: unknown[], payload: unknown): unknown;
101
- verifyPayment(payload: unknown, requirements: unknown): Promise<{
101
+ }, error: string | null, extensions?: Record<string, unknown>): Promise<PaymentRequired>;
102
+ findMatchingRequirements(requirements: PaymentRequirements[], payload: unknown): PaymentRequirements;
103
+ verifyPayment(payload: unknown, requirements: PaymentRequirements): Promise<{
102
104
  isValid: boolean;
103
105
  payer?: string;
104
106
  }>;
105
- settlePayment(payload: unknown, requirements: unknown): Promise<unknown>;
107
+ settlePayment(payload: unknown, requirements: PaymentRequirements): Promise<SettleResponse>;
106
108
  }
107
109
  type ProtocolType = 'x402' | 'mpp';
108
110
  type AuthMode = 'paid' | 'siwx' | 'apiKey' | 'unprotected';
@@ -183,6 +185,8 @@ interface RouterConfig {
183
185
  secretKey: string;
184
186
  currency: string;
185
187
  recipient?: string;
188
+ /** Tempo RPC URL for on-chain verification. Falls back to TEMPO_RPC_URL env var. */
189
+ rpcUrl?: string;
186
190
  };
187
191
  /**
188
192
  * Payment protocols to accept on auto-priced routes (those using the `prices` config).
@@ -239,6 +243,7 @@ interface OrchestrateDeps {
239
243
  secretKey: string;
240
244
  currency: string;
241
245
  recipient?: string;
246
+ rpcUrl?: string;
242
247
  };
243
248
  }
244
249
 
package/dist/index.js CHANGED
@@ -320,63 +320,103 @@ async function settleX402Payment(server, payload, requirements) {
320
320
  }
321
321
 
322
322
  // src/protocols/mpp.ts
323
- var mpayLoaded = false;
324
- var Challenge;
325
- var Credential;
326
- var Receipt;
327
- var tempo;
328
- async function ensureMpay() {
329
- if (mpayLoaded) return;
330
- try {
331
- const mpay = await import("mpay");
332
- Challenge = mpay.Challenge;
333
- Credential = mpay.Credential;
334
- Receipt = mpay.Receipt;
335
- const mpayServer = await import("mpay/server");
336
- tempo = mpayServer.tempo;
337
- mpayLoaded = true;
338
- } catch {
339
- throw new Error("mpay package is required for MPP protocol support. Install it: pnpm add mpay");
323
+ import { Challenge, Credential, Receipt } from "mpay";
324
+ import { tempo } from "mpay/server";
325
+ import { createClient, http } from "viem";
326
+ import { tempo as tempoChain } from "viem/chains";
327
+ function buildGetClient(rpcUrl) {
328
+ const url = rpcUrl ?? process.env.TEMPO_RPC_URL;
329
+ if (!url) return {};
330
+ return {
331
+ getClient: () => createClient({ chain: tempoChain, transport: http(url) })
332
+ };
333
+ }
334
+ function toStandardRequest(request) {
335
+ if (request.constructor.name === "Request") {
336
+ return request;
340
337
  }
338
+ return new Request(request.url, {
339
+ method: request.method,
340
+ headers: request.headers
341
+ });
341
342
  }
343
+ var DEFAULT_DECIMALS = 6;
342
344
  async function buildMPPChallenge(routeEntry, request, mppConfig, price) {
343
- await ensureMpay();
345
+ const standardRequest = toStandardRequest(request);
346
+ const currency = mppConfig.currency;
347
+ const recipient = mppConfig.recipient ?? "";
344
348
  const methodIntent = tempo.charge({
345
- amount: price,
346
- currency: mppConfig.currency,
347
- recipient: mppConfig.recipient ?? ""
349
+ currency,
350
+ recipient
348
351
  });
349
352
  const challenge = Challenge.fromIntent(methodIntent, {
350
353
  secretKey: mppConfig.secretKey,
351
- realm: new URL(request.url).origin,
352
- request
354
+ realm: new URL(standardRequest.url).origin,
355
+ request: {
356
+ amount: price,
357
+ currency,
358
+ recipient,
359
+ decimals: DEFAULT_DECIMALS
360
+ }
353
361
  });
354
362
  return Challenge.serialize(challenge);
355
363
  }
356
364
  async function verifyMPPCredential(request, _routeEntry, mppConfig, price) {
357
- await ensureMpay();
358
- const credential = Credential.fromRequest(request);
359
- if (!credential) return null;
360
- const isValid = Challenge.verify(credential.challenge, { secretKey: mppConfig.secretKey });
361
- if (!isValid) {
362
- return { valid: false, payer: null };
363
- }
364
- const chargeConfig = {
365
- amount: price,
366
- currency: mppConfig.currency,
367
- recipient: mppConfig.recipient ?? ""
368
- };
369
- const verifyResult = await tempo.charge(chargeConfig).verify(credential);
370
- if (!verifyResult?.valid) {
371
- return { valid: false, payer: null };
365
+ const standardRequest = toStandardRequest(request);
366
+ const currency = mppConfig.currency;
367
+ const recipient = mppConfig.recipient ?? "";
368
+ try {
369
+ const authHeader = standardRequest.headers.get("Authorization");
370
+ if (!authHeader) {
371
+ console.error("[MPP] No Authorization header found");
372
+ return null;
373
+ }
374
+ const credential = Credential.fromRequest(standardRequest);
375
+ if (!credential?.challenge) {
376
+ console.error("[MPP] Invalid credential structure");
377
+ return null;
378
+ }
379
+ const isValid = Challenge.verify(credential.challenge, { secretKey: mppConfig.secretKey });
380
+ if (!isValid) {
381
+ console.error("[MPP] Challenge HMAC verification failed");
382
+ return { valid: false, payer: null };
383
+ }
384
+ const methodIntent = tempo.charge({
385
+ currency,
386
+ recipient,
387
+ ...buildGetClient(mppConfig.rpcUrl)
388
+ });
389
+ const paymentRequest = {
390
+ amount: price,
391
+ currency,
392
+ recipient,
393
+ decimals: DEFAULT_DECIMALS
394
+ };
395
+ const resolvedRequest = methodIntent.request ? await methodIntent.request({ credential, request: paymentRequest }) : paymentRequest;
396
+ const receipt = await methodIntent.verify({
397
+ credential,
398
+ request: resolvedRequest
399
+ });
400
+ if (!receipt || receipt.status !== "success") {
401
+ console.error("[MPP] Tempo verification failed:", receipt);
402
+ return { valid: false, payer: null };
403
+ }
404
+ const payer = receipt.reference ?? "";
405
+ return {
406
+ valid: true,
407
+ payer,
408
+ txHash: receipt.reference
409
+ };
410
+ } catch (error) {
411
+ console.error("[MPP] Credential verification error:", {
412
+ message: error instanceof Error ? error.message : String(error),
413
+ stack: error instanceof Error ? error.stack : void 0,
414
+ errorType: error?.constructor?.name
415
+ });
416
+ return null;
372
417
  }
373
- return {
374
- valid: true,
375
- payer: verifyResult.payer
376
- };
377
418
  }
378
- async function buildMPPReceipt(reference) {
379
- await ensureMpay();
419
+ function buildMPPReceipt(reference) {
380
420
  const receipt = Receipt.from({
381
421
  method: "tempo",
382
422
  status: "success",
@@ -762,7 +802,12 @@ async function resolveDynamicPrice(bodyData, routeEntry, deps, pluginCtx, meta)
762
802
  }
763
803
  }
764
804
  async function build402(request, routeEntry, deps, meta, pluginCtx, bodyData) {
765
- const response = new NextResponse2(null, { status: 402 });
805
+ const response = new NextResponse2(null, {
806
+ status: 402,
807
+ headers: {
808
+ "Content-Type": "application/json"
809
+ }
810
+ });
766
811
  let challengePrice;
767
812
  if (bodyData !== void 0 && typeof routeEntry.pricing === "function") {
768
813
  const result = await resolveDynamicPrice(bodyData, routeEntry, deps, pluginCtx, meta);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentcash/router",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
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": {
@@ -19,8 +19,21 @@
19
19
  "module": "./dist/index.js",
20
20
  "types": "./dist/index.d.ts",
21
21
  "files": [
22
- "dist"
22
+ "dist",
23
+ ".claude/CLAUDE.md",
24
+ ".claude/skills"
23
25
  ],
26
+ "scripts": {
27
+ "build": "tsup",
28
+ "typecheck": "tsc --noEmit",
29
+ "lint": "eslint src/",
30
+ "lint:fix": "eslint src/ --fix",
31
+ "format": "prettier --write 'src/**/*.ts' 'tests/**/*.ts' '*.json' '*.mjs'",
32
+ "format:check": "prettier --check 'src/**/*.ts' 'tests/**/*.ts' '*.json' '*.mjs'",
33
+ "test": "vitest run",
34
+ "test:watch": "vitest",
35
+ "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm build && pnpm test"
36
+ },
24
37
  "peerDependencies": {
25
38
  "@coinbase/x402": "^2.1.0",
26
39
  "@x402/core": "^2.3.0",
@@ -31,11 +44,6 @@
31
44
  "zod": "^4.0.0",
32
45
  "zod-openapi": "^5.0.0"
33
46
  },
34
- "peerDependenciesMeta": {
35
- "mpay": {
36
- "optional": true
37
- }
38
- },
39
47
  "devDependencies": {
40
48
  "@coinbase/x402": "^2.1.0",
41
49
  "@eslint/js": "^10.0.1",
@@ -51,24 +59,15 @@
51
59
  "tsup": "^8.0.0",
52
60
  "typescript": "^5.8.0",
53
61
  "typescript-eslint": "^8.55.0",
62
+ "viem": "^2.0.0",
54
63
  "vitest": "^3.0.0",
55
64
  "zod": "^4.0.0",
56
65
  "zod-openapi": "^5.0.0"
57
66
  },
67
+ "packageManager": "pnpm@10.28.0",
58
68
  "license": "MIT",
59
69
  "repository": {
60
70
  "type": "git",
61
71
  "url": "git+https://github.com/merit-systems/agentcash-router.git"
62
- },
63
- "scripts": {
64
- "build": "tsup",
65
- "typecheck": "tsc --noEmit",
66
- "lint": "eslint src/",
67
- "lint:fix": "eslint src/ --fix",
68
- "format": "prettier --write 'src/**/*.ts' 'tests/**/*.ts' '*.json' '*.mjs'",
69
- "format:check": "prettier --check 'src/**/*.ts' 'tests/**/*.ts' '*.json' '*.mjs'",
70
- "test": "vitest run",
71
- "test:watch": "vitest",
72
- "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm build && pnpm test"
73
72
  }
74
- }
73
+ }