@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 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,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 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
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)` | 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. |
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-driven.**
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 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.
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
- **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:
193
218
  ```typescript
194
- .paid({ dynamic: true, tickCost: '0.01', unitType: 'request', maxPrice: '0.01' })
219
+ .metered({ tickCost: '0.01', maxPrice: '0.05', unitType: 'request' })
195
220
  .handler(async ({ body }) => { ... });
196
221
  ```
197
222
 
198
- **Streaming (MPP only).** One `charge()` call bills one tick:
223
+ **Streaming.** Each `charge()` call bills one tick, up to `maxPrice`:
199
224
  ```typescript
200
- .paid({ dynamic: true, tickCost: '0.0001', unitType: 'token', maxPrice: '0.05', protocols: ['mpp'] })
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
+