@agentcash/router 1.5.1 → 1.6.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/.claude/CLAUDE.md CHANGED
@@ -24,7 +24,8 @@ Protocol-agnostic route framework for Next.js App Router APIs with x402 payment,
24
24
  - `src/pricing.ts` — Price resolution (static, tiered, dynamic)
25
25
  - `src/plugin.ts` — Plugin hook system
26
26
  - `src/server.ts` — x402 server initialization
27
- - `src/auth/` — Auth modules (siwx.ts, api-key.ts, nonce.ts)
27
+ - `src/auth/` — Auth modules (siwx.ts, api-key.ts)
28
+ - `src/kv-store/` — Single KV cache layer (`client.ts` is the only file that talks to Redis; `nonce.ts`/`entitlement.ts`/`mpp.ts` are namespaced adapters on top)
28
29
  - `src/protocols/` — Protocol handlers (x402.ts, detect.ts). MPP is handled via `mppx` high-level API (`Mppx.create` in index.ts)
29
30
  - `src/discovery/` — Auto-generated endpoints (well-known.ts, openapi.ts)
30
31
 
@@ -35,10 +36,91 @@ Four auth modes, mutually exclusive (except `.apiKey()` composes with `.paid()`)
35
36
  ### `.paid(pricing)` — Payment required
36
37
  ```typescript
37
38
  .paid('0.01') // Static price
38
- .paid((body) => calcPrice(body)) // Dynamic pricing
39
+ .paid((body) => calcPrice(body)) // Dynamic pricing (body-driven, pre-handler)
39
40
  .paid({ field: 'tier', tiers: { basic: { price: '0.01' } } }) // Tiered
41
+
42
+ // Handler-driven dynamic pricing — `.handler()` request-mode bills exactly
43
+ // tickCost per request; `.stream()` streaming bills per `charge()` call.
44
+ .paid({ dynamic: true, tickCost: '0.0005', unitType: 'token', maxPrice: '0.10' })
45
+ ```
46
+
47
+ #### Handler-driven dynamic (`.paid({ dynamic: true })`)
48
+
49
+ The handler shape determines the billing model and wire transport:
50
+
51
+ **Request-mode** — `async (ctx) => value`. Bills exactly `tickCost` per
52
+ request. No `charge()` on the context — the wire commitment is fixed at
53
+ credential verification (mppx's non-SSE auto-charge for MPP, or x402 `upto`
54
+ settled for `tickCost`). This is the spec-aligned "discrete paid unit" mode
55
+ per `paymentauth.org/draft-tempo-session-00`.
56
+
57
+ ```typescript
58
+ router
59
+ .route('llm/summarize')
60
+ .paid({ dynamic: true, tickCost: '0.01', unitType: 'request', maxPrice: '0.01' })
61
+ .body(z.object({ prompt: z.string() }))
62
+ .handler(async ({ body }) => {
63
+ const summary = await callLLM(body.prompt);
64
+ return { summary }; // always bills $0.01
65
+ });
66
+ ```
67
+
68
+ For variable-cost-per-request billing, use the streaming shape below —
69
+ splitting work into yields lets `charge()` meter per unit.
70
+
71
+ **Streaming mode** — `.stream(async function* (ctx) { ... })`. Receives a
72
+ `charge()` callback on `ctx`; one call adds one tick. The invariant:
73
+
74
+ > **one `charge()` call === one tick === `tickCost` USDC === one route-defined unit**
75
+
76
+ The route picks `tickCost` to match its billing unit (one token at $0.0005,
77
+ one byte at $0.0000001, one frame at $0.001) and labels it via `unitType`.
78
+ Total billed is `tickCost × call_count`, capped at `maxPrice`. (Internally:
79
+ mppx auto-charges one prepaid tick at credential verify and marks it; the
80
+ first `charge()` consumes the prepaid without an extra debit, then subsequent
81
+ calls bill fresh ticks live.)
82
+
83
+ ```typescript
84
+ router
85
+ .route('llm/stream')
86
+ .paid({ dynamic: true, tickCost: '0.0001', unitType: 'token', maxPrice: '0.05', protocols: ['mpp'] })
87
+ .body(z.object({ prompt: z.string() }))
88
+ .stream(async function* ({ body, charge }) {
89
+ for await (const token of streamLLM(body.prompt)) {
90
+ await charge(); // one tick = one token; blocks on need-voucher
91
+ yield token;
92
+ }
93
+ yield '[DONE]'; // free trailing event — no charge before it
94
+ });
40
95
  ```
41
96
 
97
+ **Type safety**: TypeScript discriminates by which terminal method you call.
98
+ `.handler()` receives a `HandlerContext` with no `charge` — calling it is a
99
+ compile-time error. `.stream()` receives a `StreamingHandlerContext` with
100
+ `charge` and is only callable after `.paid({ dynamic: true, ... })`.
101
+
102
+ `tickCost` is required per-route on `.paid({ dynamic: true })` — the builder
103
+ throws at registration if it's missing. `unitType` is optional (cosmetic
104
+ label, defaults to undefined which mppx surfaces as plain ticks).
105
+
106
+ **Transport selection**: the router picks the wire format from the terminal
107
+ method. `.handler()` request-mode goes through plain HTTP with a
108
+ `Payment-Receipt` header (mppx's `tempo.session({ sse: false })`). `.stream()`
109
+ goes through SSE with inline per-tick voucher events
110
+ (`tempo.session({ sse: true })`). Two mppx instances run side-by-side
111
+ sharing the same store, secretKey, and realm so channel state and challenge
112
+ HMACs are interchangeable.
113
+
114
+ Both terminal methods work on x402 `upto` (settles cumulative atomic amount;
115
+ Permit2Proxy enforces ≤ maxPrice) and MPP. `.stream()` requires MPP — x402
116
+ has no streaming primitive.
117
+
118
+ **`suggestedDeposit` on MPP session 402 challenges**: defaults to
119
+ `tickCost × RouterConfig.mpp.session.depositMultiplier` (default `10`), or
120
+ the route's `maxPrice` when set. Raise the multiplier at deployment level to
121
+ cover more requests per channel, or set `maxPrice` per-route when a single
122
+ request can exceed the default budget.
123
+
42
124
  ### `.siwx()` — Wallet identity required (no payment)
43
125
  ```typescript
44
126
  .siwx().handler(async ({ wallet }) => { /* wallet is verified */ })
@@ -161,6 +243,38 @@ MPP payment verification requires an **authenticated** Tempo RPC endpoint. The p
161
243
 
162
244
  Alternatively, pass `rpcUrl` in the `mpp` config object to `createRouter()`. Without either, MPP on-chain verification fails with "unauthorized: authentication required".
163
245
 
246
+ ### MPP Server Wallets: `operatorKey` and `feePayerKey`
247
+
248
+ Two distinct roles, two distinct wallets:
249
+
250
+ - **`mpp.operatorKey`** — signs server-side on-chain ops (channel close/settle). Required for sessions. Its derived address **must equal `recipient`/payee** because mppx's close handler asserts `sender === payee` on settle.
251
+ - **`mpp.feePayerKey`** *(optional)* — sponsors gas for client-signed open/topUp txs. Omit to disable sponsorship; clients then pay their own gas.
252
+
253
+ **The two MUST resolve to different addresses when both are set.** Tempo rejects fee-delegated txs where `sender === feePayer` with `-32000 "fee payer cannot resolve to sender"`. This bites the server-signed close/settle path. The router validates the addresses at `createRouter()` time and throws `mpp_operator_equals_fee_payer` if they collide — production `next build` fails fast; dev surfaces a logged error.
254
+
255
+ ### KV Store
256
+
257
+ One KV cache backs all three persistent stores (SIWX nonce, SIWX entitlement, MPP tx-hash replay). Each consumer gets its own key prefix (`siwx:nonce:`, `siwx:ent:`, `mpp:`).
258
+
259
+ Resolution order in `createRouter`:
260
+
261
+ 1. `kvStore: { url, token }` — build the REST client from those credentials.
262
+ 2. `kvStore: <KvStore>` — bring-your-own implementation (escape hatch for Cloudflare KV, ioredis, etc.).
263
+ 3. Omitted — auto-read `KV_REST_API_URL` + `KV_REST_API_TOKEN` from `process.env`.
264
+ 4. Env missing — fall back to in-memory stores (fine for local dev, unsafe in serverless production).
265
+
266
+ ```typescript
267
+ createRouter({
268
+ kvStore: {
269
+ url: process.env.UPSTASH_REDIS_REST_URL!,
270
+ token: process.env.UPSTASH_REDIS_REST_TOKEN!,
271
+ },
272
+ // ...
273
+ });
274
+ ```
275
+
276
+ The only file that talks to Redis is `src/kv-store/client.ts`. It speaks the Upstash REST protocol with plain `fetch` (also what Vercel KV exposes). If you need a different backend, implement the `KvStore` interface and pass it as `kvStore`.
277
+
164
278
  ### CDP Environment Variables
165
279
 
166
280
  Without these keys, the default facilitator cannot authenticate with CDP:
package/README.md CHANGED
@@ -75,7 +75,6 @@ const config = {
75
75
  x402: { accepts },
76
76
  mpp: mppFromEnv(process.env, {
77
77
  recipient: payeeAddress,
78
- useDefaultStore: true,
79
78
  }),
80
79
  discovery: {
81
80
  title: 'My API',
@@ -99,8 +98,38 @@ and `TEMPO_RPC_URL`. `MPP_CURRENCY` must be the Tempo currency address; for
99
98
  Tempo USDC use `TEMPO_USDC_CURRENCY`. Optional `MPP_FEE_PAYER_KEY` is included
100
99
  when present and validated as a 32-byte EVM private key. `mppFromEnv()` only
101
100
  builds config; call `validateRouterConfig(config)` before `createRouter(config)`
102
- to fail fast on `mpp.useDefaultStore` store env (`KV_REST_API_URL` and
103
- `KV_REST_API_TOKEN`) when you use the default store.
101
+ to fail fast on misconfiguration.
102
+
103
+ ### Persistent KV store
104
+
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.
109
+
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 }`:
118
+
119
+ ```typescript
120
+ import { createRouter } from '@agentcash/router';
121
+
122
+ createRouter({
123
+ kvStore: {
124
+ url: process.env.UPSTASH_REDIS_REST_URL!,
125
+ token: process.env.UPSTASH_REDIS_REST_TOKEN!,
126
+ },
127
+ // ...
128
+ });
129
+ ```
130
+
131
+ For a different backend entirely (Cloudflare KV, ioredis, etc.), implement the
132
+ `KvStore` interface and pass your instance as `kvStore`.
104
133
 
105
134
  ## Quick Start
106
135
 
@@ -196,8 +225,8 @@ Creates a `ServiceRouter` instance.
196
225
  | `network` | `string` | `'eip155:8453'` | Blockchain network |
197
226
  | `plugin` | `RouterPlugin` | `undefined` | Observability plugin |
198
227
  | `prices` | `Record<string, string>` | `undefined` | Central pricing map (auto-applied) |
199
- | `siwx.nonceStore` | `NonceStore` | `MemoryNonceStore` | Custom nonce store |
200
- | `mpp` | `{ secretKey, currency, recipient?, rpcUrl?, feePayerKey?, useDefaultStore? }` | `undefined` | MPP config |
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 |
201
230
  | `protocols` | `('x402' \| 'mpp')[]` | `['x402']` | Default protocols for paid routes |
202
231
  | `strictRoutes` | `boolean` | `false` | Enforce `route({ path })` and prevent key/path divergence |
203
232