@agentcash/router 0.1.0
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/LICENSE +21 -0
- package/README.md +379 -0
- package/dist/index.cjs +1177 -0
- package/dist/index.d.cts +273 -0
- package/dist/index.d.ts +273 -0
- package/dist/index.js +1159 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Merit Systems
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# @agentcash/router
|
|
2
|
+
|
|
3
|
+
Unified route builder for Next.js App Router APIs with x402 payments, MPP payments, SIWX authentication, and API key auth.
|
|
4
|
+
|
|
5
|
+
Eliminates ~80-150 lines of boilerplate per route. Routes become 3-6 lines.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @agentcash/router
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependencies:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add next zod @x402/core @x402/evm @x402/extensions @coinbase/x402 zod-openapi
|
|
17
|
+
# Optional: for MPP support
|
|
18
|
+
pnpm add mpay
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
### 1. Create the router (once per service)
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
// lib/routes.ts
|
|
27
|
+
import { createRouter } from '@agentcash/router';
|
|
28
|
+
|
|
29
|
+
export const router = createRouter({
|
|
30
|
+
payeeAddress: process.env.X402_PAYEE_ADDRESS!,
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 2. Define routes
|
|
35
|
+
|
|
36
|
+
**Paid route (x402)**
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// app/api/search/route.ts
|
|
40
|
+
import { router } from '@/lib/routes';
|
|
41
|
+
import { searchSchema, searchResponseSchema } from '@/lib/schemas';
|
|
42
|
+
|
|
43
|
+
export const POST = router.route('search')
|
|
44
|
+
.paid('0.01')
|
|
45
|
+
.body(searchSchema)
|
|
46
|
+
.output(searchResponseSchema)
|
|
47
|
+
.description('Search the web')
|
|
48
|
+
.handler(async ({ body }) => search(body));
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**SIWX-authenticated route**
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
export const GET = router.route('inbox/status')
|
|
55
|
+
.siwx()
|
|
56
|
+
.query(statusQuerySchema)
|
|
57
|
+
.handler(async ({ query, wallet }) => getStatus(query, wallet));
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Unprotected route**
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
export const GET = router.route('health')
|
|
64
|
+
.unprotected()
|
|
65
|
+
.handler(async () => ({ status: 'ok' }));
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 3. Auto-discovery
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
// app/.well-known/x402/route.ts
|
|
72
|
+
import { router } from '@/lib/routes';
|
|
73
|
+
import '@/lib/routes/barrel'; // ensures all routes are imported
|
|
74
|
+
export const GET = router.wellKnown();
|
|
75
|
+
|
|
76
|
+
// app/openapi.json/route.ts
|
|
77
|
+
import { router } from '@/lib/routes';
|
|
78
|
+
import '@/lib/routes/barrel';
|
|
79
|
+
export const GET = router.openapi({ title: 'My API', version: '1.0.0' });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## API
|
|
83
|
+
|
|
84
|
+
### `createRouter(config)`
|
|
85
|
+
|
|
86
|
+
Creates a `ServiceRouter` instance.
|
|
87
|
+
|
|
88
|
+
| Option | Type | Default | Description |
|
|
89
|
+
|--------|------|---------|-------------|
|
|
90
|
+
| `payeeAddress` | `string` | **required** | Wallet address to receive payments |
|
|
91
|
+
| `network` | `string` | `'eip155:8453'` | Blockchain network |
|
|
92
|
+
| `plugin` | `RouterPlugin` | `undefined` | Observability plugin |
|
|
93
|
+
| `prices` | `Record<string, string>` | `undefined` | Central pricing map (auto-applied) |
|
|
94
|
+
| `siwx.nonceStore` | `NonceStore` | `MemoryNonceStore` | Custom nonce store |
|
|
95
|
+
| `mpp` | `{ secretKey, currency, recipient? }` | `undefined` | MPP config |
|
|
96
|
+
|
|
97
|
+
### Route Builder
|
|
98
|
+
|
|
99
|
+
The fluent builder ensures compile-time safety:
|
|
100
|
+
|
|
101
|
+
- `.paid(price)` / `.paid(fn, { maxPrice })` / `.paid({ field, tiers })` - Payment auth
|
|
102
|
+
- `.siwx()` - SIWX wallet auth
|
|
103
|
+
- `.apiKey(resolver)` - API key auth (composable with `.paid()`)
|
|
104
|
+
- `.unprotected()` - No auth
|
|
105
|
+
- `.body(zodSchema)` - Request body validation
|
|
106
|
+
- `.query(zodSchema)` - Query parameter validation
|
|
107
|
+
- `.output(zodSchema)` - Response schema (for OpenAPI)
|
|
108
|
+
- `.description(text)` - Route description (for OpenAPI)
|
|
109
|
+
- `.provider(name, config?)` - Provider monitoring (see [Provider Monitoring](#provider-monitoring))
|
|
110
|
+
- `.handler(fn)` - Terminal method, returns Next.js handler
|
|
111
|
+
|
|
112
|
+
### Pricing Modes
|
|
113
|
+
|
|
114
|
+
**Static**: `router.route('search').paid('0.02')`
|
|
115
|
+
|
|
116
|
+
**Dynamic**: `router.route('gen').paid((body) => calculateCost(body), { maxPrice: '5.00' }).body(schema)`
|
|
117
|
+
|
|
118
|
+
**Tiered**:
|
|
119
|
+
```typescript
|
|
120
|
+
router.route('upload').paid({
|
|
121
|
+
field: 'tier',
|
|
122
|
+
tiers: {
|
|
123
|
+
'10mb': { price: '0.02', label: '10 MB' },
|
|
124
|
+
'100mb': { price: '0.20', label: '100 MB' },
|
|
125
|
+
},
|
|
126
|
+
}).body(schema)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Dual Protocol (x402 + MPP)
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
router.route('search')
|
|
133
|
+
.paid('0.01', { protocols: ['x402', 'mpp'] })
|
|
134
|
+
.body(schema)
|
|
135
|
+
.handler(fn);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Handler Context
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
interface HandlerContext<TBody, TQuery> {
|
|
142
|
+
body: TBody; // Parsed + validated
|
|
143
|
+
query: TQuery; // Parsed + validated
|
|
144
|
+
request: NextRequest; // Raw request
|
|
145
|
+
wallet: string | null; // Verified wallet address
|
|
146
|
+
account: unknown; // From .apiKey() resolver
|
|
147
|
+
alert: AlertFn; // Fire observability alerts
|
|
148
|
+
setVerifiedWallet: (addr: string) => void;
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### RouterPlugin
|
|
153
|
+
|
|
154
|
+
Pluggable observability. All hooks are optional and fire-and-forget.
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
import { createRouter, type RouterPlugin } from '@agentcash/router';
|
|
158
|
+
|
|
159
|
+
const myPlugin: RouterPlugin = {
|
|
160
|
+
onRequest(meta) { /* ... */ },
|
|
161
|
+
onPaymentVerified(ctx, payment) { /* ... */ },
|
|
162
|
+
onPaymentSettled(ctx, settlement) { /* ... */ },
|
|
163
|
+
onResponse(ctx, response) { /* ... */ },
|
|
164
|
+
onError(ctx, error) { /* ... */ },
|
|
165
|
+
onAlert(ctx, alert) { /* ... */ },
|
|
166
|
+
onProviderQuota(ctx, event) { /* ... */ },
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const router = createRouter({
|
|
170
|
+
payeeAddress: process.env.X402_PAYEE_ADDRESS!,
|
|
171
|
+
plugin: myPlugin,
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Built-in `consolePlugin()` logs lifecycle events:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
import { createRouter, consolePlugin } from '@agentcash/router';
|
|
179
|
+
|
|
180
|
+
export const router = createRouter({
|
|
181
|
+
payeeAddress: process.env.X402_PAYEE_ADDRESS!,
|
|
182
|
+
plugin: consolePlugin(),
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Central Pricing Map
|
|
187
|
+
|
|
188
|
+
For services with many static-priced routes:
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
const router = createRouter({
|
|
192
|
+
payeeAddress: process.env.X402_PAYEE_ADDRESS!,
|
|
193
|
+
prices: {
|
|
194
|
+
'search': '0.02',
|
|
195
|
+
'lookup': '0.05',
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Price auto-applied, no .paid() needed
|
|
200
|
+
export const POST = router.route('search')
|
|
201
|
+
.body(schema)
|
|
202
|
+
.handler(fn);
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Provider Monitoring
|
|
206
|
+
|
|
207
|
+
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.
|
|
208
|
+
|
|
209
|
+
#### Why
|
|
210
|
+
|
|
211
|
+
Upstream providers report remaining quota in different ways:
|
|
212
|
+
|
|
213
|
+
| Pattern | Example | How detected |
|
|
214
|
+
|---------|---------|-------------|
|
|
215
|
+
| Balance in response headers | `X-RateLimit-Remaining: 482` | `extractQuota` reads headers |
|
|
216
|
+
| Balance in response body | `{ rateLimit: { remaining: 50 } }` | `extractQuota` reads result |
|
|
217
|
+
| Separate health-check endpoint | Apollo `/credits` endpoint | `monitor` function (cron) |
|
|
218
|
+
| Overages auto-charged at same rate | Exa, Firecrawl | `overage: 'same-rate'` |
|
|
219
|
+
| Overages at increased rate | Some SaaS APIs | `overage: 'increased-rate'` |
|
|
220
|
+
| No overages, immediate stoppage | Whitepages | `overage: 'hard-stop'` |
|
|
221
|
+
|
|
222
|
+
The `.provider()` method handles all six patterns through a single interface.
|
|
223
|
+
|
|
224
|
+
#### Basic usage
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
export const POST = router.route('search')
|
|
228
|
+
.paid('0.01')
|
|
229
|
+
.provider('exa', {
|
|
230
|
+
extractQuota: (result, headers) => ({
|
|
231
|
+
remaining: (result as any).rateLimit?.remaining ?? null,
|
|
232
|
+
limit: (result as any).rateLimit?.limit ?? null,
|
|
233
|
+
}),
|
|
234
|
+
warn: 100,
|
|
235
|
+
critical: 10,
|
|
236
|
+
})
|
|
237
|
+
.body(searchSchema)
|
|
238
|
+
.handler(async ({ body }) => exaClient.search(body));
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
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.
|
|
242
|
+
|
|
243
|
+
#### ProviderConfig
|
|
244
|
+
|
|
245
|
+
| Field | Type | Default | Description |
|
|
246
|
+
|-------|------|---------|-------------|
|
|
247
|
+
| `extractQuota` | `(result, headers) => QuotaInfo \| null` | — | Inline quota extraction after each request |
|
|
248
|
+
| `monitor` | `() => Promise<QuotaInfo \| null>` | — | Standalone health check (for cron) |
|
|
249
|
+
| `overage` | `'same-rate' \| 'increased-rate' \| 'hard-stop'` | `'same-rate'` | What happens when quota hits zero |
|
|
250
|
+
| `warn` | `number` | — | Fire `warn` level when remaining <= this |
|
|
251
|
+
| `critical` | `number` | — | Fire `critical` level when remaining <= this |
|
|
252
|
+
|
|
253
|
+
#### QuotaInfo
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
interface QuotaInfo {
|
|
257
|
+
remaining: number | null; // Credits/calls remaining
|
|
258
|
+
limit: number | null; // Total quota (null if unknown)
|
|
259
|
+
spend?: number; // Credits consumed this request
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
#### Threshold logic
|
|
264
|
+
|
|
265
|
+
| Condition | Level |
|
|
266
|
+
|-----------|-------|
|
|
267
|
+
| `remaining === null` | `healthy` (no data to compare) |
|
|
268
|
+
| `remaining <= critical` | `critical` |
|
|
269
|
+
| `remaining <= warn` | `warn` |
|
|
270
|
+
| Otherwise | `healthy` |
|
|
271
|
+
|
|
272
|
+
#### Plugin hook
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
interface ProviderQuotaEvent {
|
|
276
|
+
provider: string; // Provider name from .provider()
|
|
277
|
+
route: string; // Route key
|
|
278
|
+
remaining: number | null;
|
|
279
|
+
limit: number | null;
|
|
280
|
+
spend?: number;
|
|
281
|
+
level: 'healthy' | 'warn' | 'critical';
|
|
282
|
+
overage: 'same-rate' | 'increased-rate' | 'hard-stop';
|
|
283
|
+
message: string; // Human-readable summary
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Handle in your plugin:
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
const myPlugin: RouterPlugin = {
|
|
291
|
+
onProviderQuota(ctx, event) {
|
|
292
|
+
if (event.level === 'critical') {
|
|
293
|
+
discord.alert(`${event.provider}: ${event.remaining} remaining`);
|
|
294
|
+
}
|
|
295
|
+
clickhouse.insert('provider_quota', event);
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
#### Cron monitors
|
|
301
|
+
|
|
302
|
+
For providers that require a separate API call to check balance (not available inline in response), register a `monitor` function:
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
export const POST = router.route('people/search')
|
|
306
|
+
.paid('0.05')
|
|
307
|
+
.provider('apollo', {
|
|
308
|
+
monitor: async () => {
|
|
309
|
+
const res = await fetch('https://api.apollo.io/v1/credits', {
|
|
310
|
+
headers: { 'X-Api-Key': process.env.APOLLO_KEY! },
|
|
311
|
+
});
|
|
312
|
+
const data = await res.json();
|
|
313
|
+
return { remaining: data.credits, limit: null };
|
|
314
|
+
},
|
|
315
|
+
overage: 'hard-stop',
|
|
316
|
+
warn: 500,
|
|
317
|
+
critical: 50,
|
|
318
|
+
})
|
|
319
|
+
.body(searchSchema)
|
|
320
|
+
.handler(fn);
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Retrieve all registered monitors via `router.monitors()`:
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
// cron.ts — run every 5 minutes
|
|
327
|
+
import { router } from '@/lib/routes';
|
|
328
|
+
import '@/lib/routes/barrel';
|
|
329
|
+
|
|
330
|
+
for (const entry of router.monitors()) {
|
|
331
|
+
const quota = await entry.monitor();
|
|
332
|
+
if (!quota) continue;
|
|
333
|
+
|
|
334
|
+
const level = quota.remaining !== null && quota.remaining <= (entry.critical ?? 0)
|
|
335
|
+
? 'critical'
|
|
336
|
+
: quota.remaining !== null && quota.remaining <= (entry.warn ?? 0)
|
|
337
|
+
? 'warn'
|
|
338
|
+
: 'healthy';
|
|
339
|
+
|
|
340
|
+
if (level !== 'healthy') {
|
|
341
|
+
alert(`${entry.provider} (${entry.route}): ${quota.remaining} remaining [${level}]`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
`monitors()` returns:
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
interface MonitorEntry {
|
|
350
|
+
provider: string;
|
|
351
|
+
route: string;
|
|
352
|
+
monitor: () => Promise<QuotaInfo | null>;
|
|
353
|
+
overage: OveragePolicy;
|
|
354
|
+
warn?: number;
|
|
355
|
+
critical?: number;
|
|
356
|
+
}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
#### Provider name only
|
|
360
|
+
|
|
361
|
+
If you just want to tag a route with its provider for logging/tracing, pass only the name:
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
router.route('health')
|
|
365
|
+
.unprotected()
|
|
366
|
+
.provider('internal')
|
|
367
|
+
.handler(async () => ({ status: 'ok' }));
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
#### Safety guarantees
|
|
371
|
+
|
|
372
|
+
- `extractQuota` runs fire-and-forget — exceptions are caught and swallowed
|
|
373
|
+
- `extractQuota` only runs when `response.status < 400` (no quota extraction on errors)
|
|
374
|
+
- The plugin hook is non-blocking — it never delays the response to the caller
|
|
375
|
+
- Missing thresholds are fine — without `warn`/`critical`, level is always `healthy`
|
|
376
|
+
|
|
377
|
+
## License
|
|
378
|
+
|
|
379
|
+
MIT
|