@agentcash/router 1.7.1 → 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 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. Auth and pricing are orthogonal: `.paid()`, `.siwx()`, `.apiKey()`, `.unprotected()`.
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) + env reader (from-env.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, siwx-only, api-key-only, unprotected)
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 validation, RouterConfigError, issue codes
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']` has `domain`, `uri`, `version`, `chainId`, `type`, `nonce`, `issuedAt`.
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-driven pricing.** Body is parsed before the 402 challenge via `request.clone()` when pricing is a function. `maxPrice` is optional and acts as a cap and a fallback on pricing function errors.
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 MPP.** `.stream()` only works on MPP. x402 has no streaming primitive.
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 (production) | Coinbase Developer Platform credentials for the default EVM facilitator. T3 / `@t3-oss/env-nextjs` users must declare these in their env schema. |
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. **Dynamic pricing (`upto`) is Base-only** Solana clients can only pay static-priced routes. |
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 streaming + `.paid({ dynamic: true })` on MPP). Address must equal the payee. |
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 used as the 402 realm, OpenAPI server URL, and MPP memo prefix. Must match the public domain. |
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 `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.
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 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.
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,15 +139,19 @@ 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 Next.js otherwise lazy-loads route files on first hit, and unloaded routes don't appear in the spec.
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
+
144
+ The `openapi.json` should be hosted at `GET <origin>/openapi.json`.
143
145
 
144
146
  ## Auth modes
145
147
 
146
148
  | Method | Purpose |
147
149
  |--------|---------|
148
- | `.paid(price)` | Payment required (x402, MPP, or both). |
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. |
149
153
  | `.siwx()` | Wallet identity, no payment. Returns 402 with a SIWX challenge. |
150
- | `.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()`. |
151
155
  | `.unprotected()` | No auth. |
152
156
 
153
157
  ```typescript
@@ -162,18 +166,22 @@ router.route({ path: 'gated' })
162
166
 
163
167
  ## Pricing
164
168
 
169
+ `.paid()`, `.upTo()`, and `.metered()` are mutually exclusive pricing modes: pick one per route.
170
+
171
+ ### `.paid()`: fixed, args-derived, or tiered
172
+
165
173
  **Static.**
166
174
  ```typescript
167
175
  .paid('0.02')
168
176
  ```
169
177
 
170
- **Args-driven.**
178
+ **Args-derived.** Compute the price from the parsed body. Throw `HttpError` to reject before the 402 challenge.
171
179
  ```typescript
172
180
  .paid((body) => calculateCost(body), { maxPrice: '5.00' })
173
181
  .body(genSchema)
174
182
  ```
175
183
 
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.
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.
177
185
 
178
186
  **Tiered.**
179
187
  ```typescript
@@ -187,15 +195,34 @@ router.route({ path: 'gated' })
187
195
  .body(uploadSchema)
188
196
  ```
189
197
 
190
- **Handler-driven (request-mode).** Bills exactly `tickCost` per request:
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:
191
218
  ```typescript
192
- .paid({ dynamic: true, tickCost: '0.01', unitType: 'request', maxPrice: '0.01' })
219
+ .metered({ tickCost: '0.01', maxPrice: '0.05', unitType: 'request' })
193
220
  .handler(async ({ body }) => { ... });
194
221
  ```
195
222
 
196
- **Streaming (MPP only).** One `charge()` call bills one tick:
223
+ **Streaming.** Each `charge()` call bills one tick, up to `maxPrice`:
197
224
  ```typescript
198
- .paid({ dynamic: true, tickCost: '0.0001', unitType: 'token', maxPrice: '0.05', protocols: ['mpp'] })
225
+ .metered({ tickCost: '0.0001', maxPrice: '0.05', unitType: 'token' })
199
226
  .stream(async function* ({ body, charge }) {
200
227
  for await (const token of streamLLM(body.prompt)) {
201
228
  await charge();
@@ -204,6 +231,8 @@ router.route({ path: 'gated' })
204
231
  });
205
232
  ```
206
233
 
234
+ Streaming is MPP-only. `.stream()` on a `.paid()` / `.upTo()` / `.unprotected()` route throws at registration.
235
+
207
236
  ## Pre-payment validation
208
237
 
209
238
  For checks that need a DB lookup before quoting a price:
@@ -246,3 +275,21 @@ export const router = createRouterFromEnv({
246
275
 
247
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.
248
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
+