@agentcash/router 1.8.0 → 1.9.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/AGENTS.md +17 -11
- package/README.md +61 -16
- package/dist/index.cjs +141 -104
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +141 -104
- package/package.json +3 -3
package/AGENTS.md
CHANGED
|
@@ -10,7 +10,7 @@ A protocol-agnostic route framework for Next.js App Router APIs. Provides x402 p
|
|
|
10
10
|
|
|
11
11
|
1. Route definition is 3 to 6 lines.
|
|
12
12
|
2. Single source of truth: the route registry drives discovery, OpenAPI, and pricing.
|
|
13
|
-
3.
|
|
13
|
+
3. Pricing modes (`.paid()`, `.upTo()`, `.metered()`) and identity auth (`.siwx()`, `.apiKey()`) compose; `.unprotected()` opts out. Exactly one pricing mode per route.
|
|
14
14
|
4. Observability is pluggable via `RouterPlugin`. No boilerplate.
|
|
15
15
|
5. The package owns x402 and MPP server lifecycles (init, verify, settle).
|
|
16
16
|
6. Compose, do not reimplement. Delegate to `@x402/*`, `@coinbase/x402`, and `mppx`.
|
|
@@ -20,36 +20,42 @@ A protocol-agnostic route framework for Next.js App Router APIs. Provides x402 p
|
|
|
20
20
|
```
|
|
21
21
|
src/
|
|
22
22
|
index.ts public surface — createRouter / createRouterFromEnv
|
|
23
|
-
builder.ts fluent RouteBuilder
|
|
23
|
+
builder.ts fluent RouteBuilder (.paid / .upTo / .metered / .siwx / .apiKey / .unprotected)
|
|
24
24
|
registry.ts Map-backed route registry
|
|
25
25
|
constants.ts network ids, USDC asset/decimals, default facilitator
|
|
26
|
-
types.ts core types (RouteEntry, HandlerContext, HttpError)
|
|
26
|
+
types.ts core types (RouteEntry, HandlerContext, HttpError, PaidOptions, UpToOptions, MeteredOptions)
|
|
27
27
|
plugin/ RouterPlugin types + lifecycle dispatch
|
|
28
|
-
init/ protocol init (x402.ts, mpp.ts
|
|
28
|
+
init/ protocol init (x402.ts, x402-server.ts, mpp.ts, mppx.ts)
|
|
29
29
|
protocols/ x402/ and mpp/ strategies, detect.ts, accepts
|
|
30
30
|
auth/ siwx.ts, api-key.ts, normalize-wallet.ts
|
|
31
31
|
kv-store/ one KvStore backs siwx nonce, siwx entitlement, mpp replay
|
|
32
|
-
pricing/ fixed, tiered, dynamic, atomic conversion
|
|
33
|
-
pipeline/ flows (paid
|
|
32
|
+
pricing/ fixed, tiered, dynamic (args-derived), upto-charge, metered-charge, format (atomic conversion)
|
|
33
|
+
pipeline/ orchestrate.ts + steps/ + flows/ (paid → static-paid | dynamic-paid; siwx-only, api-key-only, unprotected)
|
|
34
34
|
discovery/ well-known, openapi, llms-txt
|
|
35
|
-
config/ RouterConfig
|
|
35
|
+
config/ RouterConfig + env schema (single source of truth), RouterConfigError, issue codes
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
## Pipeline
|
|
39
39
|
|
|
40
40
|
`auth check -> body parse -> validate -> 402 challenge -> payment verify -> handler -> settle -> finalize`
|
|
41
41
|
|
|
42
|
+
## Naming
|
|
43
|
+
|
|
44
|
+
Constructor-style functions use `build<Noun>` — one verb, one domain noun. Name them after the domain concept, never after an HTTP status code or transport detail (the function that builds a payment challenge is `buildChallengeResponse`, not `build402`). The challenge family shares the `Challenge` backbone: `buildChallengeResponse`, `buildChallengeExtensions`, `buildSiwxChallenge`, `buildSessionChallenge`, `buildX402Challenge`.
|
|
45
|
+
|
|
42
46
|
## Critical rules
|
|
43
47
|
|
|
44
48
|
- **Error handling.** Respect `.status` on any thrown error, not just `HttpError`. Pattern: `throw Object.assign(new Error('msg'), { status: 409 })`.
|
|
45
|
-
- **SIWX challenge.** Must return an x402v2 challenge with `PAYMENT-REQUIRED` header and a JSON body whose `extensions['sign-in-with-x']`
|
|
46
|
-
- **Discovery visibility.** `authMode !== 'unprotected'` determines well-known visibility, not the protocol list. SIWX routes are discoverable.
|
|
49
|
+
- **SIWX challenge.** Must return an x402v2 challenge with a `PAYMENT-REQUIRED` header and a JSON body whose `extensions['sign-in-with-x']` carries `info` (an object with `domain`, `uri`, `version`, `chainId`, `type`, `nonce`, `issuedAt`, `expirationTime`, `statement`), `supportedChains`, and an optional `schema`. The header-encoded challenge and the JSON body must stay identical.
|
|
50
|
+
- **Discovery visibility.** `authMode !== 'unprotected'` determines well-known visibility, not the protocol list. SIWX routes are discoverable. All three pricing modes set `authMode = 'paid'`; the `billing` field (`'exact' | 'upto' | 'metered'`) distinguishes them downstream.
|
|
47
51
|
- **OpenAPI.** Merge paths for multi-method endpoints (GET + DELETE on same path). Never overwrite.
|
|
48
52
|
- **Duplicate route keys.** Registry silently overwrites (last write wins) with a dev-only `console.warn`. This is intentional: Next.js module load order is non-deterministic, so stub + real handler may register either order.
|
|
49
|
-
- **Args-
|
|
53
|
+
- **Args-derived pricing.** Body is parsed before the 402 challenge via `request.clone()` when `.paid(fn)` is used. `maxPrice` is optional: it caps the computed amount and acts as a fallback on non-`HttpError` exceptions; `HttpError` is always rethrown so a pricing function can reject the request with its intended status before any payment is taken.
|
|
54
|
+
- **`.upTo()` is x402-only.** Builder throws if `protocols` overrides it to anything else. Requires an `'upto'` accept on at least one configured x402 network — `createRouterFromEnv` auto-adds one on Base; programmatic `createRouter` users must add `{ scheme: 'upto', network, asset }` to `x402.accepts` themselves.
|
|
55
|
+
- **`.metered()` is MPP-only.** Builder throws if `protocols` overrides it to anything else. Requires `RouterConfig.mpp.session` and `mpp.operatorKey`. `createRouterFromEnv` enables both automatically when `MPP_OPERATOR_KEY` is set.
|
|
50
56
|
- **MPP operator vs fee-payer.** `mpp.operatorKey` and `mpp.feePayerKey` MUST resolve to different addresses. Tempo rejects fee-delegated txs where `sender === feePayer`. `createRouter` validates this at construction and throws `mpp_operator_equals_fee_payer`.
|
|
51
57
|
- **MPP operator address.** Must equal `recipient` / payee. mppx's close handler asserts `sender === payee` on settle.
|
|
52
|
-
- **Streaming requires
|
|
58
|
+
- **Streaming requires `.metered()`.** `.stream()` on a `.paid()` / `.upTo()` / `.unprotected()` route throws at registration. x402 has no streaming primitive, so `.stream()` is MPP-only by construction.
|
|
53
59
|
|
|
54
60
|
## Two entry points
|
|
55
61
|
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
26
|
pnpm add @agentcash/router
|
|
27
|
-
pnpm add next zod @x402/core @x402/evm @x402/extensions @coinbase/x402 zod-openapi # peer dependencies
|
|
27
|
+
pnpm add next zod @x402/core @x402/evm @x402/extensions @x402/svm @coinbase/x402 zod-openapi # peer dependencies
|
|
28
28
|
pnpm add mppx # optional, for MPP support
|
|
29
29
|
```
|
|
30
30
|
|
|
@@ -37,13 +37,13 @@ The recommended entry point reads its config from `process.env`. A copy-paste `.
|
|
|
37
37
|
| Var | Required | Purpose |
|
|
38
38
|
|-----|----------|---------|
|
|
39
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 (
|
|
40
|
+
| `CDP_API_KEY_ID`, `CDP_API_KEY_SECRET` | yes (EVM) | Coinbase Developer Platform credentials for the default EVM facilitator. Create an API key at https://portal.cdp.coinbase.com. T3 / `@t3-oss/env-nextjs` users must declare these in their env schema. |
|
|
41
41
|
|
|
42
42
|
### Solana
|
|
43
43
|
|
|
44
44
|
| Var | Required | Purpose |
|
|
45
45
|
|-----|----------|---------|
|
|
46
|
-
| `SOLANA_PAYEE_ADDRESS` | no | When set, adds a Solana `exact` accept so the router takes Solana payments.
|
|
46
|
+
| `SOLANA_PAYEE_ADDRESS` | no | When set, adds a Solana `exact` accept so the router takes Solana payments. **`.upTo()` is Base-only and `.metered()` is MPP-only**. Solana clients can only pay static-priced `.paid()` routes. |
|
|
47
47
|
| `SOLANA_FACILITATOR_URL` | no | Override the Solana x402 facilitator. Defaults to `DEFAULT_SOLANA_FACILITATOR_URL`. |
|
|
48
48
|
|
|
49
49
|
### MPP (auto-enabled when `MPP_SECRET_KEY` is set)
|
|
@@ -53,14 +53,14 @@ The recommended entry point reads its config from `process.env`. A copy-paste `.
|
|
|
53
53
|
| `MPP_SECRET_KEY` | when MPP is enabled | Server-side MPP secret. Presence toggles MPP on. |
|
|
54
54
|
| `MPP_CURRENCY` | when MPP is enabled | Tempo currency address. Use `TEMPO_USDC_ADDRESS` for Tempo USDC. |
|
|
55
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
|
|
56
|
+
| `MPP_OPERATOR_KEY` | no | Signs server-side close/settle. When set, MPP session mode is enabled automatically (required for `.metered()`: both streaming and request-mode per-tick billing). Address must equal the payee. |
|
|
57
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`). |
|
|
58
58
|
|
|
59
59
|
### Other
|
|
60
60
|
|
|
61
61
|
| Var | Required | Purpose |
|
|
62
62
|
|-----|----------|---------|
|
|
63
|
-
| `BASE_URL` | yes | Origin URL (`https://api.example.com`). Load-bearing
|
|
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
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. |
|
|
65
65
|
|
|
66
66
|
## Quick start
|
|
@@ -69,7 +69,7 @@ The recommended entry point reads its config from `process.env`. A copy-paste `.
|
|
|
69
69
|
|
|
70
70
|
There are two ways to initialize. Pick one.
|
|
71
71
|
|
|
72
|
-
**Option A
|
|
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.
|
|
73
73
|
|
|
74
74
|
```typescript
|
|
75
75
|
// lib/router.ts
|
|
@@ -82,7 +82,7 @@ export const router = createRouterFromEnv({
|
|
|
82
82
|
});
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
**Option B
|
|
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.
|
|
86
86
|
|
|
87
87
|
```typescript
|
|
88
88
|
// lib/router.ts
|
|
@@ -139,7 +139,7 @@ import '@/lib/routes-barrel'; // imports every route module so the registry is
|
|
|
139
139
|
export const GET = router.openapi();
|
|
140
140
|
```
|
|
141
141
|
|
|
142
|
-
The barrel forces every route module to load before the discovery handler walks the registry
|
|
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.
|
|
143
143
|
|
|
144
144
|
The `openapi.json` should be hosted at `GET <origin>/openapi.json`.
|
|
145
145
|
|
|
@@ -147,9 +147,11 @@ The `openapi.json` should be hosted at `GET <origin>/openapi.json`.
|
|
|
147
147
|
|
|
148
148
|
| Method | Purpose |
|
|
149
149
|
|--------|---------|
|
|
150
|
-
| `.paid(price)` |
|
|
150
|
+
| `.paid(price)` | Fixed, args-derived, or tiered payment up front (x402, MPP, or both). |
|
|
151
|
+
| `.upTo(maxPrice)` | Handler-computed billing; handler calls `charge(amount)` and the request settles once for the running total. **x402 only.** |
|
|
152
|
+
| `.metered({ tickCost, maxPrice })` | Per-tick billing over an MPP payment channel. `.handler()` bills exactly `tickCost`; `.stream()` calls `charge()` per yield. **MPP only.** Streaming requires this. |
|
|
151
153
|
| `.siwx()` | Wallet identity, no payment. Returns 402 with a SIWX challenge. |
|
|
152
|
-
| `.apiKey(resolver)` | `X-API-Key` or `Authorization: Bearer <key>`. Composes with `.paid()`. |
|
|
154
|
+
| `.apiKey(resolver)` | `X-API-Key` or `Authorization: Bearer <key>`. Composes with `.paid()` / `.upTo()` / `.metered()`. |
|
|
153
155
|
| `.unprotected()` | No auth. |
|
|
154
156
|
|
|
155
157
|
```typescript
|
|
@@ -164,18 +166,22 @@ router.route({ path: 'gated' })
|
|
|
164
166
|
|
|
165
167
|
## Pricing
|
|
166
168
|
|
|
169
|
+
`.paid()`, `.upTo()`, and `.metered()` are mutually exclusive pricing modes: pick one per route.
|
|
170
|
+
|
|
171
|
+
### `.paid()`: fixed, args-derived, or tiered
|
|
172
|
+
|
|
167
173
|
**Static.**
|
|
168
174
|
```typescript
|
|
169
175
|
.paid('0.02')
|
|
170
176
|
```
|
|
171
177
|
|
|
172
|
-
**Args-
|
|
178
|
+
**Args-derived.** Compute the price from the parsed body. Throw `HttpError` to reject before the 402 challenge.
|
|
173
179
|
```typescript
|
|
174
180
|
.paid((body) => calculateCost(body), { maxPrice: '5.00' })
|
|
175
181
|
.body(genSchema)
|
|
176
182
|
```
|
|
177
183
|
|
|
178
|
-
`maxPrice` caps the computed amount and acts as a fallback
|
|
184
|
+
`maxPrice` caps the computed amount and acts as a fallback on non-`HttpError` exceptions thrown by the pricing function (`HttpError` is always rethrown). Without `maxPrice`, the route trusts your function fully (no cap, no fallback) and returns 500 on errors.
|
|
179
185
|
|
|
180
186
|
**Tiered.**
|
|
181
187
|
```typescript
|
|
@@ -189,15 +195,34 @@ router.route({ path: 'gated' })
|
|
|
189
195
|
.body(uploadSchema)
|
|
190
196
|
```
|
|
191
197
|
|
|
192
|
-
|
|
198
|
+
### `.upTo()`: handler-computed, x402 only
|
|
199
|
+
|
|
200
|
+
Handler calls `charge(amount)` one or more times; the request settles once for the accumulated total, capped at `maxPrice`. Requires an `'upto'` accept on at least one configured x402 network (`createRouterFromEnv` auto-adds one on Base).
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
.upTo('0.05')
|
|
204
|
+
.body(schema)
|
|
205
|
+
.handler(async ({ body, charge }) => {
|
|
206
|
+
await charge('0.001');
|
|
207
|
+
// ... more work ...
|
|
208
|
+
await charge('0.002');
|
|
209
|
+
return result;
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### `.metered()`: per-tick, MPP only
|
|
214
|
+
|
|
215
|
+
Per-tick billing over an MPP payment channel. Requires `MPP_OPERATOR_KEY` (`createRouterFromEnv` auto-enables session mode when it's set).
|
|
216
|
+
|
|
217
|
+
**Request-mode.** `.handler()` bills exactly `tickCost` on each request:
|
|
193
218
|
```typescript
|
|
194
|
-
.
|
|
219
|
+
.metered({ tickCost: '0.01', maxPrice: '0.05', unitType: 'request' })
|
|
195
220
|
.handler(async ({ body }) => { ... });
|
|
196
221
|
```
|
|
197
222
|
|
|
198
|
-
**Streaming
|
|
223
|
+
**Streaming.** Each `charge()` call bills one tick, up to `maxPrice`:
|
|
199
224
|
```typescript
|
|
200
|
-
.
|
|
225
|
+
.metered({ tickCost: '0.0001', maxPrice: '0.05', unitType: 'token' })
|
|
201
226
|
.stream(async function* ({ body, charge }) {
|
|
202
227
|
for await (const token of streamLLM(body.prompt)) {
|
|
203
228
|
await charge();
|
|
@@ -206,6 +231,8 @@ router.route({ path: 'gated' })
|
|
|
206
231
|
});
|
|
207
232
|
```
|
|
208
233
|
|
|
234
|
+
Streaming is MPP-only. `.stream()` on a `.paid()` / `.upTo()` / `.unprotected()` route throws at registration.
|
|
235
|
+
|
|
209
236
|
## Pre-payment validation
|
|
210
237
|
|
|
211
238
|
For checks that need a DB lookup before quoting a price:
|
|
@@ -248,3 +275,21 @@ export const router = createRouterFromEnv({
|
|
|
248
275
|
|
|
249
276
|
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.
|
|
250
277
|
|
|
278
|
+
### Debugging with `onAlert`
|
|
279
|
+
|
|
280
|
+
The router reports internal warnings and errors (failed payment verification, simulation failures, misconfiguration) through `onAlert`: with no plugin registered these messages are silently dropped, so wiring up a logging plugin is the fastest way to see why a request failed. Forward `onAlert` (and `onError`) to your logs:
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
const loggingPlugin: RouterPlugin = {
|
|
284
|
+
onAlert(_ctx, alert) {
|
|
285
|
+
(alert.level === 'error' ? console.error : console.warn)(
|
|
286
|
+
`[router:${alert.route}] ${alert.message}`,
|
|
287
|
+
alert.meta ?? '',
|
|
288
|
+
);
|
|
289
|
+
},
|
|
290
|
+
onError(_ctx, error) {
|
|
291
|
+
console.error(`[router] ${error.status} ${error.message} (settled=${error.settled})`);
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
```
|
|
295
|
+
|