@agentcash/router 1.6.0 → 1.7.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.
package/README.md CHANGED
@@ -1,185 +1,130 @@
1
- # @agentcash/router
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://agentcash.dev/logo-dark-striped.svg">
4
+ <img alt="AgentCash" src="https://agentcash.dev/logo-light-striped.svg" width="360">
5
+ </picture>
6
+ </p>
2
7
 
3
- Unified route builder for Next.js App Router APIs with x402 payments, MPP payments, SIWX authentication, and API key auth.
8
+ <h1 align="center">@agentcash/router</h1>
4
9
 
5
- Eliminates ~80-150 lines of boilerplate per route. Routes become 3-6 lines.
10
+ <p align="center">
11
+ <strong>The fastest way to ship an API on x402 and MPP.</strong><br/>
12
+ x402 and MPP payments, compatible discovery, and minimal boilerplate. With @agentcash/router, agents on <a href="https://agentcash.dev">AgentCash</a> and across the agentic commerce ecosystem are compatible and call your endpoints from day one.
13
+ </p>
6
14
 
7
- ## Install
15
+ <p align="center">
16
+ <a href="https://www.npmjs.com/package/@agentcash/router"><img alt="npm" src="https://img.shields.io/npm/v/@agentcash/router.svg?color=111&label=npm"></a>
17
+ <a href="https://agentcash.dev/docs"><img alt="docs" src="https://img.shields.io/badge/docs-agentcash.dev-111"></a>
18
+ <a href="#install"><img alt="next.js" src="https://img.shields.io/badge/Next.js-App%20Router-111"></a>
19
+ </p>
8
20
 
9
- ```bash
10
- pnpm add @agentcash/router
11
- ```
21
+ ---
12
22
 
13
- Peer dependencies:
23
+ ## Install
14
24
 
15
25
  ```bash
16
- pnpm add next zod @x402/core @x402/evm @x402/extensions @coinbase/x402 zod-openapi
17
- # Optional: for MPP support
18
- pnpm add mppx
26
+ pnpm add @agentcash/router
27
+ pnpm add next zod @x402/core @x402/evm @x402/extensions @coinbase/x402 zod-openapi # peer dependencies
28
+ pnpm add mppx # optional, for MPP support
19
29
  ```
20
30
 
21
- ## Environment Setup
31
+ ## Environment
22
32
 
23
- The router uses the default facilitator from `@coinbase/x402` for x402 payments, which requires CDP API keys:
33
+ The recommended entry point reads its config from `process.env`. A copy-paste `.env.example` lives at the repo root.
24
34
 
25
- ```bash
26
- CDP_API_KEY_ID=your-key-id
27
- CDP_API_KEY_SECRET=your-key-secret
28
- ```
35
+ ### x402
29
36
 
30
- **For Next.js apps with env validation** (T3 stack, `@t3-oss/env-nextjs`): Add these to your env schema — Next.js doesn't expose undeclared env vars to `process.env`.
37
+ | Var | Required | Purpose |
38
+ |-----|----------|---------|
39
+ | `EVM_PAYEE_ADDRESS` | yes | EVM address that receives x402 and MPP payments (`0x…`, 20 bytes). Canonicalized to lowercase. The zero address is rejected. |
40
+ | `CDP_API_KEY_ID`, `CDP_API_KEY_SECRET` | yes (production) | Coinbase Developer Platform credentials for the default EVM facilitator. T3 / `@t3-oss/env-nextjs` users must declare these in their env schema. |
31
41
 
32
- ```typescript
33
- // src/env.js
34
- import { createEnv } from "@t3-oss/env-nextjs";
35
- import { z } from "zod";
36
-
37
- export const env = createEnv({
38
- server: {
39
- CDP_API_KEY_ID: z.string(),
40
- CDP_API_KEY_SECRET: z.string(),
41
- },
42
- runtimeEnv: {
43
- CDP_API_KEY_ID: process.env.CDP_API_KEY_ID,
44
- CDP_API_KEY_SECRET: process.env.CDP_API_KEY_SECRET,
45
- },
46
- });
47
- ```
42
+ ### Solana
48
43
 
49
- Without these keys, x402 routes that use the default EVM facilitator will fail to initialize.
50
- `createRouter(config)` always validates the base URL and protocol list, throws protocol config
51
- errors in production, and keeps protocol-specific init errors as request-time JSON 500s in
52
- development. Call `validateRouterConfig(config)` before `createRouter(config)` when you want
53
- missing facilitator, MPP, or store configuration to throw immediately in every environment.
44
+ | Var | Required | Purpose |
45
+ |-----|----------|---------|
46
+ | `SOLANA_PAYEE_ADDRESS` | no | When set, adds a Solana `exact` accept so the router takes Solana payments. **Dynamic pricing (`upto`) is Base-only** Solana clients can only pay static-priced routes. |
47
+ | `SOLANA_FACILITATOR_URL` | no | Override the Solana x402 facilitator. Defaults to `DEFAULT_SOLANA_FACILITATOR_URL`. |
54
48
 
55
- ### Recommended strict setup
49
+ ### MPP (auto-enabled when `MPP_SECRET_KEY` is set)
56
50
 
57
- ```typescript
58
- import {
59
- createRouter,
60
- mppFromEnv,
61
- validateRouterConfig,
62
- x402AcceptsFromEnv,
63
- type ProtocolType,
64
- } from '@agentcash/router';
65
-
66
- const payeeAddress = process.env.X402_WALLET_ADDRESS ?? process.env.X402_PAYEE_ADDRESS;
67
- const accepts = x402AcceptsFromEnv(process.env, { payeeAddress });
68
- const protocols: ProtocolType[] = process.env.MPP_SECRET_KEY ? ['x402', 'mpp'] : ['x402'];
69
-
70
- const config = {
71
- payeeAddress: payeeAddress!,
72
- baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
73
- strictRoutes: true,
74
- protocols,
75
- x402: { accepts },
76
- mpp: mppFromEnv(process.env, {
77
- recipient: payeeAddress,
78
- }),
79
- discovery: {
80
- title: 'My API',
81
- version: '1.0.0',
82
- },
83
- };
51
+ | Var | Required | Purpose |
52
+ |-----|----------|---------|
53
+ | `MPP_SECRET_KEY` | when MPP is enabled | Server-side MPP secret. Presence toggles MPP on. |
54
+ | `MPP_CURRENCY` | when MPP is enabled | Tempo currency address. Use `TEMPO_USDC_ADDRESS` for Tempo USDC. |
55
+ | `TEMPO_RPC_URL` | when MPP is enabled | Authenticated Tempo JSON-RPC endpoint. Public `rpc.tempo.xyz` returns 401. |
56
+ | `MPP_OPERATOR_KEY` | no | Signs server-side close/settle. When set, MPP session mode is enabled automatically (required for streaming + `.paid({ dynamic: true })` on MPP). Address must equal the payee. |
57
+ | `MPP_FEE_PAYER_KEY` | no | Sponsors client gas for channel open/topUp. Must resolve to a different address than `MPP_OPERATOR_KEY` (Tempo rejects fee-delegated txs where `sender === feePayer`). |
84
58
 
85
- validateRouterConfig(config);
86
- export const router = createRouter(config);
87
- ```
59
+ ### Other
88
60
 
89
- `x402AcceptsFromEnv()` always adds Base (`BASE_NETWORK`) and also adds Solana
90
- mainnet (`SOLANA_MAINNET_NETWORK`) when `SOLANA_PAYEE_ADDRESS` is set. Solana
91
- addresses are case-sensitive and are preserved as-is. It reads
92
- `X402_WALLET_ADDRESS` by default; older services that still use
93
- `X402_PAYEE_ADDRESS` can pass `{ payeeEnv: 'X402_PAYEE_ADDRESS' }`.
61
+ | Var | Required | Purpose |
62
+ |-----|----------|---------|
63
+ | `BASE_URL` | yes | Origin URL (`https://api.example.com`). Load-bearing — used as the 402 realm, OpenAPI server URL, and MPP memo prefix. Must match the public domain. |
64
+ | `KV_REST_API_URL`, `KV_REST_API_TOKEN` | no | Upstash / Vercel KV. Backs SIWX nonce, SIWX entitlement, and MPP replay. In-memory fallback is unsafe in serverless production. Providing a Kv Store is highly recommended. |
94
65
 
95
- `mppFromEnv()` returns `undefined` when no MPP env vars are present. If any MPP
96
- env var is present, the full trio is required: `MPP_SECRET_KEY`, `MPP_CURRENCY`,
97
- and `TEMPO_RPC_URL`. `MPP_CURRENCY` must be the Tempo currency address; for
98
- Tempo USDC use `TEMPO_USDC_CURRENCY`. Optional `MPP_FEE_PAYER_KEY` is included
99
- when present and validated as a 32-byte EVM private key. `mppFromEnv()` only
100
- builds config; call `validateRouterConfig(config)` before `createRouter(config)`
101
- to fail fast on misconfiguration.
66
+ ## Quick start
102
67
 
103
- ### Persistent KV store
68
+ ### 1. Create the router
104
69
 
105
- The router uses a single KV cache for three things: SIWX nonce replay
106
- protection, SIWX entitlement records, and MPP tx-hash replay protection. Each
107
- consumer gets its own key prefix (`siwx:nonce:`, `siwx:ent:`, `mpp:`) so one
108
- Redis/Upstash instance serves all three.
70
+ There are two ways to initialize. Pick one.
109
71
 
110
- `createRouter` reads `KV_REST_API_URL` + `KV_REST_API_TOKEN` from `process.env`
111
- automatically (Vercel KV and Upstash both set these). If both are present, it
112
- builds an Upstash-compatible REST client and wires it into every store. If
113
- either is missing, all three stores fall back to in-memory — fine for local
114
- dev, unsafe in serverless production.
115
-
116
- To use REST credentials under a different env name, pass them as
117
- `{ url, token }`:
72
+ **Option A — `createRouterFromEnv` (recommended).** Reads `process.env`, validates every value up front, and throws a single `RouterConfigError` with every problem at once. Auto-enables MPP when `MPP_SECRET_KEY` is set, auto-adds a Solana accept when `SOLANA_PAYEE_ADDRESS` is set, auto-enables MPP session mode when `MPP_OPERATOR_KEY` is set.
118
73
 
119
74
  ```typescript
120
- import { createRouter } from '@agentcash/router';
75
+ // lib/router.ts
76
+ import { createRouterFromEnv } from '@agentcash/router';
121
77
 
122
- createRouter({
123
- kvStore: {
124
- url: process.env.UPSTASH_REDIS_REST_URL!,
125
- token: process.env.UPSTASH_REDIS_REST_TOKEN!,
126
- },
127
- // ...
78
+ export const router = createRouterFromEnv({
79
+ title: 'My API',
80
+ description: 'Pay-per-call search.',
81
+ guidance: 'POST /search with { q: string }. Returns top 10 results.',
128
82
  });
129
83
  ```
130
84
 
131
- For a different backend entirely (Cloudflare KV, ioredis, etc.), implement the
132
- `KvStore` interface and pass your instance as `kvStore`.
133
-
134
- ## Quick Start
135
-
136
- ### 1. Create the router (once per service)
85
+ **Option B — build a `RouterConfig` and pass it to `createRouter`.** Use this when you need custom networks, multiple payees, non-standard assets, or any setting `createRouterFromEnv` doesn't expose. `createRouter` runs the same validation against the `RouterConfig` shape.
137
86
 
138
87
  ```typescript
139
- // lib/routes.ts
140
- import { createRouter } from '@agentcash/router';
88
+ // lib/router.ts
89
+ import { createRouter, BASE_MAINNET_NETWORK } from '@agentcash/router';
141
90
 
142
91
  export const router = createRouter({
143
- payeeAddress: process.env.X402_WALLET_ADDRESS!,
144
- baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
145
- strictRoutes: true, // recommended
92
+ baseUrl: 'https://api.example.com',
93
+ payeeAddress: '0x…',
94
+ network: BASE_MAINNET_NETWORK,
95
+ protocols: ['x402'],
96
+ x402: { accepts: [/* … */] },
146
97
  discovery: {
147
98
  title: 'My API',
148
99
  version: '1.0.0',
149
- description: 'Pay-per-call API',
100
+ description: 'Pay-per-call search.',
101
+ guidance: 'POST /search with { q: string }. Returns top 10 results.',
150
102
  },
151
103
  });
152
104
  ```
153
105
 
154
106
  ### 2. Define routes
155
107
 
156
- **Paid route (x402)**
157
-
158
108
  ```typescript
159
109
  // app/api/search/route.ts
160
- import { router } from '@/lib/routes';
161
- import { searchSchema, searchResponseSchema } from '@/lib/schemas';
110
+ import { router } from '@/lib/router';
111
+ import { searchSchema } from '@/lib/schemas';
162
112
 
163
113
  export const POST = router.route({ path: 'search' })
164
114
  .paid('0.01')
165
115
  .body(searchSchema)
166
- .output(searchResponseSchema)
167
- .description('Search the web')
168
116
  .handler(async ({ body }) => search(body));
169
117
  ```
170
118
 
171
- **SIWX-authenticated route**
172
-
173
119
  ```typescript
120
+ // app/api/inbox/status/route.ts
174
121
  export const GET = router.route({ path: 'inbox/status' })
175
122
  .siwx()
176
- .query(statusQuerySchema)
177
- .handler(async ({ query, wallet }) => getStatus(query, wallet));
123
+ .handler(async ({ wallet }) => getStatus(wallet));
178
124
  ```
179
125
 
180
- **Unprotected route**
181
-
182
126
  ```typescript
127
+ // app/api/health/route.ts
183
128
  export const GET = router.route({ path: 'health' })
184
129
  .unprotected()
185
130
  .handler(async () => ({ status: 'ok' }));
@@ -187,479 +132,117 @@ export const GET = router.route({ path: 'health' })
187
132
 
188
133
  ### 3. Auto-discovery
189
134
 
190
- Discovery metadata (`title`, `version`, `description`, `guidance`) is configured once in `createRouter({ discovery })`. The discovery handlers are zero-arg:
191
-
192
135
  ```typescript
193
- // app/.well-known/x402/route.ts
194
- import { router } from '@/lib/routes';
195
- import '@/lib/routes/barrel'; // ensures all routes are imported
196
- export const GET = router.wellKnown();
197
-
198
136
  // app/openapi.json/route.ts
199
- import { router } from '@/lib/routes';
200
- import '@/lib/routes/barrel';
137
+ import { router } from '@/lib/router';
138
+ import '@/lib/routes-barrel'; // imports every route module so the registry is populated
201
139
  export const GET = router.openapi();
202
-
203
- // app/llms.txt/route.ts
204
- import { router } from '@/lib/routes';
205
- export const GET = router.llmsTxt();
206
140
  ```
207
141
 
208
- OpenAPI output follows the discovery contract:
142
+ The barrel forces every route module to load before the discovery handler walks the registry — Next.js otherwise lazy-loads route files on first hit, and unloaded routes don't appear in the spec.
209
143
 
210
- - Paid signaling via `responses.402` + `x-payment-info`
211
- - Auth signaling via `security` + `components.securitySchemes`
212
- - Optional top-level metadata via `x-discovery` (`ownershipProofs`)
144
+ ## Auth modes
213
145
 
214
- ## API
215
-
216
- ### `createRouter(config)`
217
-
218
- Creates a `ServiceRouter` instance.
219
-
220
- | Option | Type | Default | Description |
221
- |--------|------|---------|-------------|
222
- | `payeeAddress` | `string` | — | Wallet address to receive payments |
223
- | `baseUrl` | `string` | **required** | Service origin used for discovery/OpenAPI/realm |
224
- | `discovery` | `DiscoveryConfig` | **required** | Title, version, description, guidance for OpenAPI/well-known/llms.txt |
225
- | `network` | `string` | `'eip155:8453'` | Blockchain network |
226
- | `plugin` | `RouterPlugin` | `undefined` | Observability plugin |
227
- | `prices` | `Record<string, string>` | `undefined` | Central pricing map (auto-applied) |
228
- | `kvStore` | `KvStore \| { url, token }` | auto from `KV_REST_API_URL` + `KV_REST_API_TOKEN`, else memory | Single KV cache for SIWX nonce, SIWX entitlement, and MPP tx-hash replay |
229
- | `mpp` | `{ secretKey, currency, recipient?, rpcUrl?, feePayerKey?, session? }` | `undefined` | MPP config |
230
- | `protocols` | `('x402' \| 'mpp')[]` | `['x402']` | Default protocols for paid routes |
231
- | `strictRoutes` | `boolean` | `false` | Enforce `route({ path })` and prevent key/path divergence |
232
-
233
- ### Config validation helpers
146
+ | Method | Purpose |
147
+ |--------|---------|
148
+ | `.paid(price)` | Payment required (x402, MPP, or both). |
149
+ | `.siwx()` | Wallet identity, no payment. Returns 402 with a SIWX challenge. |
150
+ | `.apiKey(resolver)` | `X-API-Key` or `Authorization: Bearer <key>`. Composes with `.paid()`. |
151
+ | `.unprotected()` | No auth. |
234
152
 
235
153
  ```typescript
236
- import {
237
- BASE_NETWORK,
238
- SOLANA_MAINNET_NETWORK,
239
- TEMPO_USDC_CURRENCY,
240
- getRouterConfigIssues,
241
- mppFromEnv,
242
- paidOptionsForProtocols,
243
- validateRouterConfig,
244
- x402AcceptsFromEnv,
245
- } from '@agentcash/router';
246
- ```
247
-
248
- - `validateRouterConfig(config)` throws `RouterConfigError` with structured
249
- issues. Use it when you want invalid env/config to fail at startup in every
250
- environment; `createRouter(config)` still performs its own validation and
251
- keeps deferred protocol init errors in development for request-time feedback.
252
- - `getRouterConfigIssues(config)` returns the same structured issues without
253
- throwing.
254
- - `x402AcceptsFromEnv(env)` builds Base and optional Solana x402 accepts from
255
- `X402_WALLET_ADDRESS` and `SOLANA_PAYEE_ADDRESS`.
256
- - `mppFromEnv(env)` builds MPP config only when MPP env is present, and rejects
257
- partial MPP env.
258
- - `paidOptionsForProtocols(protocols)` copies a protocol array into a
259
- route-level `PaidOptions` object.
260
- - Manual `.paid(price)` routes inherit `createRouter({ protocols })` unless
261
- the route passes its own `options.protocols`.
262
-
263
- ### Path-First Routing
264
-
265
- Use path-first route definitions to keep runtime, OpenAPI, and discovery aligned:
266
-
267
- ```typescript
268
- router.route({ path: 'flightaware/airports/id/flights/arrivals', method: 'GET' })
269
- ```
154
+ router.route({ path: 'admin/users' })
155
+ .apiKey(async (key) => db.admin.findByKey(key)) // null => 401
156
+ .handler(async ({ account }) => db.user.findMany());
270
157
 
271
- If you need a custom internal key (legacy pricing map), you can pass:
272
-
273
- ```typescript
274
- router.route({ path: 'public/path', key: 'legacy/key' })
158
+ router.route({ path: 'gated' })
159
+ .apiKey(resolver).paid('0.01') // key AND payment
160
+ .handler(fn);
275
161
  ```
276
162
 
277
- In `strictRoutes` mode, custom keys are rejected to prevent discovery drift.
278
-
279
- ### Route Builder
280
-
281
- The fluent builder ensures compile-time safety:
163
+ ## Pricing
282
164
 
283
- - `.paid(price)` / `.paid(fn, { maxPrice })` / `.paid({ field, tiers })` - Payment auth
284
- - `.siwx()` - SIWX wallet auth
285
- - `.apiKey(resolver)` - API key auth (composable with `.paid()`)
286
- - `.unprotected()` - No auth
287
- - `.body(zodSchema, example?)` - Request body validation with optional discovery example
288
- - `.query(zodSchema, example?)` - Query parameter validation with optional discovery example
289
- - `.output(zodSchema, example?)` - Response schema with optional discovery example
290
- - `.inputExample(sample)` / `.outputExample(sample)` - Optional examples when a separate call reads better
291
- - `.description(text)` - Route description (for OpenAPI)
292
- - `.provider(name, config?)` - Provider monitoring (see [Provider Monitoring](#provider-monitoring))
293
- - `.settlement({ beforeSettle, afterSettle, onSettledHandlerError, onSettlementError })` - Payment lifecycle hooks
294
- - `.handler(fn)` - Terminal method, returns Next.js handler
295
-
296
- ### Pricing Modes
297
-
298
- **Static** - Fixed price for all requests:
165
+ **Static.**
299
166
  ```typescript
300
- router.route('search').paid('0.02')
167
+ .paid('0.02')
301
168
  ```
302
169
 
303
- **Dynamic** - Calculate price based on request body:
170
+ **Args-driven.**
304
171
  ```typescript
305
- router.route('gen')
306
- .paid((body) => calculateCost(body.imageSize, body.quality))
307
- .body(imageGenSchema)
308
- .handler(async ({ body }) => generate(body));
172
+ .paid((body) => calculateCost(body), { maxPrice: '5.00' })
173
+ .body(genSchema)
309
174
  ```
310
175
 
311
- **Dynamic with safety net** - Cap at maxPrice if calculation exceeds, fallback to maxPrice on errors:
312
- ```typescript
313
- router.route('compute')
314
- .paid((body) => calculateExpensiveOperation(body), { maxPrice: '10.00' })
315
- .body(computeSchema)
316
- .handler(async ({ body }) => compute(body));
317
- ```
176
+ `maxPrice` caps the computed amount and acts as a fallback if the pricing function throws. Without `maxPrice`, the route trusts your function fully (no cap, no fallback) and returns 500 on errors.
318
177
 
319
- **Tiered** - Price based on a specific field value:
178
+ **Tiered.**
320
179
  ```typescript
321
- router.route('upload').paid({
180
+ .paid({
322
181
  field: 'tier',
323
182
  tiers: {
324
183
  '10mb': { price: '0.02', label: '10 MB' },
325
184
  '100mb': { price: '0.20', label: '100 MB' },
326
185
  },
327
- }).body(uploadSchema)
186
+ })
187
+ .body(uploadSchema)
328
188
  ```
329
189
 
330
- #### maxPrice Semantics (v0.3.1+)
331
-
332
- `maxPrice` is **optional** for dynamic pricing and acts as a safety net:
333
-
334
- 1. **Capping**: If `calculateCost(body)` returns `"15.00"` but `maxPrice: "10.00"`, the client is charged `$10.00` (capped) and a warning alert fires.
335
-
336
- 2. **Fallback**: If `calculateCost(body)` throws an error and `maxPrice` is set, the route falls back to `maxPrice` (degraded mode) and an alert fires. Without `maxPrice`, the route returns 500.
337
-
338
- 3. **Trust mode**: No `maxPrice` means full trust in your pricing function (no cap, no fallback).
339
-
340
- **Best practices:**
341
- - ✅ Always set `maxPrice` for production routes (safety net)
342
- - ✅ Use `maxPrice` for routes with external dependencies (pricing APIs)
343
- - ✅ Monitor alerts for capping events (indicates pricing bug)
344
- - ⚠️ Skip `maxPrice` only for well-tested, unbounded pricing (e.g., per-GB storage)
345
-
346
- **Example with safety net:**
347
- ```typescript
348
- router.route('ai-gen')
349
- .paid(async (body) => {
350
- // External pricing API (can fail)
351
- const res = await fetch('https://pricing.example.com/calculate', {
352
- method: 'POST',
353
- body: JSON.stringify(body),
354
- });
355
- return res.json().price;
356
- }, { maxPrice: '5.00' }) // Fallback if API is down
357
- .body(genSchema)
358
- .handler(async ({ body }) => generate(body));
359
- ```
360
-
361
- ### Dual Protocol (x402 + MPP)
362
-
190
+ **Handler-driven (request-mode).** Bills exactly `tickCost` per request:
363
191
  ```typescript
364
- router.route('search')
365
- .paid('0.01', { protocols: ['x402', 'mpp'] })
366
- .body(schema)
367
- .handler(fn);
192
+ .paid({ dynamic: true, tickCost: '0.01', unitType: 'request', maxPrice: '0.01' })
193
+ .handler(async ({ body }) => { ... });
368
194
  ```
369
195
 
370
- ### Handler Context
371
-
196
+ **Streaming (MPP only).** One `charge()` call bills one tick:
372
197
  ```typescript
373
- interface HandlerContext<TBody, TQuery> {
374
- body: TBody; // Parsed + validated
375
- query: TQuery; // Parsed + validated
376
- request: NextRequest; // Raw request
377
- wallet: string | null; // Verified wallet address
378
- payment: HandlerPaymentContext | null; // Payment metadata for this request
379
- account: unknown; // From .apiKey() resolver
380
- alert: AlertFn; // Fire observability alerts
381
- setVerifiedWallet: (addr: string) => void;
382
- }
198
+ .paid({ dynamic: true, tickCost: '0.0001', unitType: 'token', maxPrice: '0.05', protocols: ['mpp'] })
199
+ .stream(async function* ({ body, charge }) {
200
+ for await (const token of streamLLM(body.prompt)) {
201
+ await charge();
202
+ yield token;
203
+ }
204
+ });
383
205
  ```
384
206
 
385
- `payment` is `null` for unprotected, API-key-only, and SIWX-only requests. For
386
- paid requests it includes `protocol`, `status`, `payer`, `amount`, `network`,
387
- and best-effort recipient/transaction/receipt metadata when the protocol
388
- provides it. x402 handlers currently see `status: 'verified'` because settlement
389
- happens after a successful handler response.
390
-
391
- ### Settlement Lifecycle
207
+ ## Pre-payment validation
392
208
 
393
- For paid routes, use `.settlement()` when final checks belong after handler work
394
- but before router-controlled settlement:
209
+ For checks that need a DB lookup before quoting a price:
395
210
 
396
211
  ```typescript
397
- router.route('render')
398
- .paid('0.10')
399
- .body(schema, { prompt: 'city at dusk' })
400
- .settlement({
401
- beforeSettle: async ({ result }) => {
402
- if (!isUsableResult(result)) {
403
- throw Object.assign(new Error('Render failed validation'), { status: 502 });
404
- }
405
- },
406
- afterSettle: async ({ payment, result }) => {
407
- await ledger.record({ tx: payment.transaction, result });
408
- },
409
- onSettledHandlerError: async ({ payment, error }) => {
410
- await compensationQueue.enqueue({ receipt: payment.receipt, error });
411
- },
212
+ router.route({ path: 'domain/register' })
213
+ .paid(calculatePrice, { maxPrice: '10.00' })
214
+ .body(RegisterSchema)
215
+ .validate(async (body) => {
216
+ if (await isDomainTaken(body.domain)) {
217
+ throw Object.assign(new Error('Domain taken'), { status: 409 });
218
+ }
412
219
  })
413
- .handler(async ({ body }) => render(body));
220
+ .handler(async ({ body, wallet }) => registerDomain(body.domain, wallet));
414
221
  ```
415
222
 
416
- `beforeSettle` can still prevent the charge for x402 and MPP
417
- transaction-payload flows. `afterSettle` is for durable ledgers, analytics, and
418
- post-settlement bookkeeping. `onSettledHandlerError` covers already-settled
419
- MPP requests whose handler returns an error response, which is the right place
420
- to enqueue app-owned refund or compensation work. The router cannot generically
421
- refund protocol payments because it does not hold merchant signing keys.
422
-
423
- ### RouterPlugin
223
+ Pipeline order: `body parse -> validate -> 402 challenge -> payment -> handler`.
424
224
 
425
- Pluggable observability. All hooks are optional and fire-and-forget.
225
+ ## Plugin Hooks
426
226
 
427
227
  ```typescript
428
- import { createRouter, type RouterPlugin } from '@agentcash/router';
228
+ import { createRouterFromEnv, type RouterPlugin } from '@agentcash/router';
429
229
 
430
230
  const myPlugin: RouterPlugin = {
431
- onRequest(meta) { /* ... */ },
432
- onPaymentVerified(ctx, payment) { /* ... */ },
433
- onPaymentSettled(ctx, settlement) { /* ... */ },
434
- onResponse(ctx, response) { /* ... */ },
435
- onError(ctx, error) { /* ... */ },
436
- onAlert(ctx, alert) { /* ... */ },
437
- onProviderQuota(ctx, event) { /* ... */ },
231
+ onRequest(meta) {},
232
+ onPaymentVerified(ctx, payment) {},
233
+ onPaymentSettled(ctx, settlement) {},
234
+ onResponse(ctx, response) {},
235
+ onError(ctx, error) {},
236
+ onAlert(ctx, alert) {},
438
237
  };
439
238
 
440
- export const router = createRouter({
441
- payeeAddress: process.env.X402_WALLET_ADDRESS!,
442
- baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
239
+ export const router = createRouterFromEnv({
240
+ title: 'My API',
241
+ description: '…',
242
+ guidance: '…',
443
243
  plugin: myPlugin,
444
- discovery: {
445
- title: 'My API',
446
- version: '1.0.0',
447
- },
448
- });
449
- ```
450
-
451
- Built-in `consolePlugin()` logs lifecycle events:
452
-
453
- ```typescript
454
- import { createRouter, consolePlugin } from '@agentcash/router';
455
-
456
- export const router = createRouter({
457
- payeeAddress: process.env.X402_WALLET_ADDRESS!,
458
- baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
459
- plugin: consolePlugin(),
460
- discovery: {
461
- title: 'My API',
462
- version: '1.0.0',
463
- },
464
- });
465
- ```
466
-
467
- ### Central Pricing Map
468
-
469
- For services with many static-priced routes:
470
-
471
- ```typescript
472
- const router = createRouter({
473
- payeeAddress: process.env.X402_WALLET_ADDRESS!,
474
- baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
475
- discovery: {
476
- title: 'My API',
477
- version: '1.0.0',
478
- },
479
- prices: {
480
- 'search': '0.02',
481
- 'lookup': '0.05',
482
- },
483
244
  });
484
-
485
- // Price auto-applied, no .paid() needed
486
- export const POST = router.route('search')
487
- .body(schema)
488
- .handler(fn);
489
245
  ```
490
246
 
491
- ### Provider Monitoring
492
-
493
- Routes that wrap third-party APIs can declare monitoring behavior per-provider. This surfaces quota/balance information through the plugin system and registers cron-checkable monitors.
494
-
495
- #### Why
496
-
497
- Upstream providers report remaining quota in different ways:
498
-
499
- | Pattern | Example | How detected |
500
- |---------|---------|-------------|
501
- | Balance in response headers | `X-RateLimit-Remaining: 482` | `extractQuota` reads headers |
502
- | Balance in response body | `{ rateLimit: { remaining: 50 } }` | `extractQuota` reads result |
503
- | Separate health-check endpoint | Apollo `/credits` endpoint | `monitor` function (cron) |
504
- | Overages auto-charged at same rate | Exa, Firecrawl | `overage: 'same-rate'` |
505
- | Overages at increased rate | Some SaaS APIs | `overage: 'increased-rate'` |
506
- | No overages, immediate stoppage | Whitepages | `overage: 'hard-stop'` |
507
-
508
- The `.provider()` method handles all six patterns through a single interface.
509
-
510
- #### Basic usage
511
-
512
- ```typescript
513
- export const POST = router.route('search')
514
- .paid('0.01')
515
- .provider('exa', {
516
- extractQuota: (result, headers) => ({
517
- remaining: (result as any).rateLimit?.remaining ?? null,
518
- limit: (result as any).rateLimit?.limit ?? null,
519
- }),
520
- warn: 100,
521
- critical: 10,
522
- })
523
- .body(searchSchema)
524
- .handler(async ({ body }) => exaClient.search(body));
525
- ```
526
-
527
- After every successful handler response, `extractQuota` runs with the raw handler result and the response headers. The router computes a level (`healthy`, `warn`, `critical`) based on thresholds and fires `onProviderQuota` on the plugin.
528
-
529
- #### ProviderConfig
530
-
531
- | Field | Type | Default | Description |
532
- |-------|------|---------|-------------|
533
- | `extractQuota` | `(result, headers) => QuotaInfo \| null` | — | Inline quota extraction after each request |
534
- | `monitor` | `() => Promise<QuotaInfo \| null>` | — | Standalone health check (for cron) |
535
- | `overage` | `'same-rate' \| 'increased-rate' \| 'hard-stop'` | `'same-rate'` | What happens when quota hits zero |
536
- | `warn` | `number` | — | Fire `warn` level when remaining <= this |
537
- | `critical` | `number` | — | Fire `critical` level when remaining <= this |
538
-
539
- #### QuotaInfo
540
-
541
- ```typescript
542
- interface QuotaInfo {
543
- remaining: number | null; // Credits/calls remaining
544
- limit: number | null; // Total quota (null if unknown)
545
- spend?: number; // Credits consumed this request
546
- }
547
- ```
548
-
549
- #### Threshold logic
550
-
551
- | Condition | Level |
552
- |-----------|-------|
553
- | `remaining === null` | `healthy` (no data to compare) |
554
- | `remaining <= critical` | `critical` |
555
- | `remaining <= warn` | `warn` |
556
- | Otherwise | `healthy` |
557
-
558
- #### Plugin hook
559
-
560
- ```typescript
561
- interface ProviderQuotaEvent {
562
- provider: string; // Provider name from .provider()
563
- route: string; // Route key
564
- remaining: number | null;
565
- limit: number | null;
566
- spend?: number;
567
- level: 'healthy' | 'warn' | 'critical';
568
- overage: 'same-rate' | 'increased-rate' | 'hard-stop';
569
- message: string; // Human-readable summary
570
- }
571
- ```
572
-
573
- Handle in your plugin:
574
-
575
- ```typescript
576
- const myPlugin: RouterPlugin = {
577
- onProviderQuota(ctx, event) {
578
- if (event.level === 'critical') {
579
- discord.alert(`${event.provider}: ${event.remaining} remaining`);
580
- }
581
- clickhouse.insert('provider_quota', event);
582
- },
583
- };
584
- ```
585
-
586
- #### Cron monitors
587
-
588
- For providers that require a separate API call to check balance (not available inline in response), register a `monitor` function:
589
-
590
- ```typescript
591
- export const POST = router.route('people/search')
592
- .paid('0.05')
593
- .provider('apollo', {
594
- monitor: async () => {
595
- const res = await fetch('https://api.apollo.io/v1/credits', {
596
- headers: { 'X-Api-Key': process.env.APOLLO_KEY! },
597
- });
598
- const data = await res.json();
599
- return { remaining: data.credits, limit: null };
600
- },
601
- overage: 'hard-stop',
602
- warn: 500,
603
- critical: 50,
604
- })
605
- .body(searchSchema)
606
- .handler(fn);
607
- ```
608
-
609
- Retrieve all registered monitors via `router.monitors()`:
610
-
611
- ```typescript
612
- // cron.ts — run every 5 minutes
613
- import { router } from '@/lib/routes';
614
- import '@/lib/routes/barrel';
615
-
616
- for (const entry of router.monitors()) {
617
- const quota = await entry.monitor();
618
- if (!quota) continue;
619
-
620
- const level = quota.remaining !== null && quota.remaining <= (entry.critical ?? 0)
621
- ? 'critical'
622
- : quota.remaining !== null && quota.remaining <= (entry.warn ?? 0)
623
- ? 'warn'
624
- : 'healthy';
625
-
626
- if (level !== 'healthy') {
627
- alert(`${entry.provider} (${entry.route}): ${quota.remaining} remaining [${level}]`);
628
- }
629
- }
630
- ```
631
-
632
- `monitors()` returns:
633
-
634
- ```typescript
635
- interface MonitorEntry {
636
- provider: string;
637
- route: string;
638
- monitor: () => Promise<QuotaInfo | null>;
639
- overage: OveragePolicy;
640
- warn?: number;
641
- critical?: number;
642
- }
643
- ```
644
-
645
- #### Provider name only
646
-
647
- If you just want to tag a route with its provider for logging/tracing, pass only the name:
648
-
649
- ```typescript
650
- router.route('health')
651
- .unprotected()
652
- .provider('internal')
653
- .handler(async () => ({ status: 'ok' }));
654
- ```
655
-
656
- #### Safety guarantees
657
-
658
- - `extractQuota` runs fire-and-forget — exceptions are caught and swallowed
659
- - `extractQuota` only runs when `response.status < 400` (no quota extraction on errors)
660
- - The plugin hook is non-blocking — it never delays the response to the caller
661
- - Missing thresholds are fine — without `warn`/`critical`, level is always `healthy`
662
-
663
- ## License
247
+ All hooks are optional and fire-and-forget; they never delay the response. Use hooks to add additional telemetry or flexibility to your resource's lifecycle.
664
248
 
665
- MIT