@agentcash/router 1.6.0 → 1.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentcash/router",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "Unified route builder for Next.js App Router APIs with x402, MPP, SIWX, and API key auth",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,8 +20,8 @@
20
20
  "types": "./dist/index.d.ts",
21
21
  "files": [
22
22
  "dist",
23
- ".claude/CLAUDE.md",
24
- ".claude/skills"
23
+ "AGENTS.md",
24
+ "README.md"
25
25
  ],
26
26
  "peerDependencies": {
27
27
  "@coinbase/x402": "^2.1.0",
@@ -42,25 +42,16 @@
42
42
  "devDependencies": {
43
43
  "@changesets/cli": "^2.29.8",
44
44
  "@coinbase/x402": "^2.1.0",
45
- "@crossmint/lobster-cli": "^0.1.6",
46
45
  "@eslint/js": "^10.0.1",
47
- "@faremeter/facilitator": "0.17.1",
48
- "@faremeter/fetch": "^0.17.1",
49
- "@faremeter/info": "^0.17.1",
50
- "@faremeter/payment-solana": "^0.17.1",
51
- "@faremeter/types": "^0.17.1",
52
- "@faremeter/wallet-solana": "^0.17.1",
53
- "@faremeter/x-solana-settlement": "^0.4.0",
54
46
  "@modelcontextprotocol/sdk": "^1.26.0",
55
47
  "@solana/kit": "^5.1.0",
56
- "@solana/spl-token": "^0.4.14",
57
- "@solana/web3.js": "^1.98.4",
58
48
  "@types/node": "^22.0.0",
59
49
  "@x402/core": "^2.11.0",
60
50
  "@x402/evm": "^2.11.0",
61
51
  "@x402/extensions": "^2.11.0",
62
52
  "@x402/svm": "^2.11.0",
63
53
  "eslint": "^10.0.0",
54
+ "knip": "^6.13.1",
64
55
  "mppx": "^0.6.16",
65
56
  "next": "^15.0.0",
66
57
  "prettier": "^3.8.1",
@@ -87,6 +78,7 @@
87
78
  "format:check": "prettier --check 'src/**/*.ts' 'tests/**/*.ts' '*.json' '*.mjs'",
88
79
  "test": "vitest run",
89
80
  "test:watch": "vitest",
90
- "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm build && pnpm test"
81
+ "knip": "knip",
82
+ "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm knip && pnpm build && pnpm test"
91
83
  }
92
84
  }
package/.claude/CLAUDE.md DELETED
@@ -1,343 +0,0 @@
1
- # CLAUDE.md — @agentcash/router
2
-
3
- Protocol-agnostic route framework for Next.js App Router APIs with x402 payment, MPP payment, SIWX identity auth, and API key auth. Used by stablestudio, enrichx402, x402email, agentfacilitator, agentupload.
4
-
5
- ## Guiding Principles
6
-
7
- 1. **Route definition is 3-6 lines.** Everything else is derived.
8
- 2. **Single source of truth.** Route registry drives discovery, OpenAPI, pricing, Bazaar schemas.
9
- 3. **Auth and pricing are orthogonal and composable.** `.paid()`, `.siwx()`, `.apiKey()`, `.unprotected()`.
10
- 4. **Observability is pluggable via RouterPlugin.** Zero boilerplate.
11
- 5. **The package owns the x402 server lifecycle.** Init, verify, settle.
12
- 6. **Convention over configuration.** Sane defaults for Base, USDC, exact scheme.
13
- 7. **Compose, don't reimplement.** Zero payment/auth protocol logic — delegates to `@x402/*`, `@coinbase/x402`, and `mppx`.
14
-
15
- ## Architecture
16
-
17
- **Orchestrate pipeline:** auth check -> body parse -> price resolve -> payment verify -> handler invoke -> settle -> finalize
18
-
19
- - `src/orchestrate.ts` — Full request lifecycle orchestration
20
- - `src/builder.ts` — Fluent RouteBuilder API
21
- - `src/handler.ts` — Safe handler invocation with error mapping
22
- - `src/types.ts` — Core types (RouteEntry, HandlerContext, HttpError)
23
- - `src/registry.ts` — Route registry (Map-backed, silent overwrite on duplicate keys)
24
- - `src/pricing.ts` — Price resolution (static, tiered, dynamic)
25
- - `src/plugin.ts` — Plugin hook system
26
- - `src/server.ts` — x402 server initialization
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)
29
- - `src/protocols/` — Protocol handlers (x402.ts, detect.ts). MPP is handled via `mppx` high-level API (`Mppx.create` in index.ts)
30
- - `src/discovery/` — Auto-generated endpoints (well-known.ts, openapi.ts)
31
-
32
- ## Auth Modes
33
-
34
- Four auth modes, mutually exclusive (except `.apiKey()` composes with `.paid()`):
35
-
36
- ### `.paid(pricing)` — Payment required
37
- ```typescript
38
- .paid('0.01') // Static price
39
- .paid((body) => calcPrice(body)) // Dynamic pricing (body-driven, pre-handler)
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
- });
95
- ```
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
-
124
- ### `.siwx()` — Wallet identity required (no payment)
125
- ```typescript
126
- .siwx().handler(async ({ wallet }) => { /* wallet is verified */ })
127
- ```
128
-
129
- ### `.apiKey(resolver)` — API key / Bearer token auth
130
- For admin routes, cron jobs, internal services. Checks `X-API-Key` header OR `Authorization: Bearer <token>`.
131
-
132
- ```typescript
133
- // Admin route with API key
134
- export const GET = router
135
- .route('admin/users')
136
- .apiKey(async (key) => {
137
- const admin = await db.admin.findByKey(key);
138
- return admin ?? null; // null = 401, truthy = ctx.account
139
- })
140
- .handler(async ({ account }) => {
141
- // account is whatever resolver returned
142
- return db.user.findMany();
143
- });
144
-
145
- // Cron job with static secret
146
- export const POST = router
147
- .route('cron/cleanup')
148
- .apiKey((key) => key === process.env.CRON_SECRET ? { cron: true } : null)
149
- .handler(async () => { /* ... */ });
150
- ```
151
-
152
- **Headers accepted:** `X-API-Key: <key>` or `Authorization: Bearer <key>`
153
-
154
- **Composing with payment:** `.apiKey()` can layer on `.paid()` — auth runs first, payment second:
155
- ```typescript
156
- .apiKey(resolver).paid('0.01') // Must pass API key AND pay
157
- ```
158
-
159
- ### `.unprotected()` — No auth
160
- ```typescript
161
- .unprotected().handler(async () => { /* public endpoint */ })
162
- ```
163
-
164
- ## Pre-Payment Validation
165
-
166
- ### `.validate(fn)` — Async business validation before 402 challenge
167
-
168
- For checks that need DB lookups or external APIs before showing a price. Runs after body parsing, before the 402 challenge. Requires `.body()`.
169
-
170
- ```typescript
171
- // Domain registration with availability check
172
- router
173
- .route('domain/register')
174
- .paid(calculatePrice, { maxPrice: '10.00' })
175
- .body(RegisterSchema) // .body() before .validate() for type inference
176
- .validate(async (body) => {
177
- if (await isDomainTaken(body.domain)) {
178
- throw Object.assign(new Error('Domain already taken'), { status: 409 });
179
- }
180
- })
181
- .handler(async ({ body, wallet }) => {
182
- return registerDomain(body.domain, wallet);
183
- });
184
-
185
- // Rate limiting before payment
186
- router
187
- .route('api/expensive')
188
- .paid('1.00')
189
- .body(RequestSchema)
190
- .validate(async (body) => {
191
- const usage = await getUserUsage(body.userId);
192
- if (usage >= DAILY_LIMIT) {
193
- throw Object.assign(new Error('Daily limit reached'), { status: 429 });
194
- }
195
- })
196
- .handler(async ({ body }) => { ... });
197
- ```
198
-
199
- **Pipeline order:** `body parse → validate → 402 challenge → payment → handler`
200
-
201
- **Error handling:** Respects `.status` on thrown errors (default: 400). Use `Object.assign(new Error('msg'), { status: 409 })` for custom codes.
202
-
203
- **Works with all auth modes:** paid, siwx, apiKey, unprotected.
204
-
205
- ## Critical Rules
206
-
207
- - **Error handling:** Respect `.status` on any thrown error, not just `HttpError`. The `Object.assign(new Error(), { status })` pattern is universal in Node.js.
208
- - **SIWX challenge:** Must return a proper x402v2 challenge with `PAYMENT-REQUIRED` header and JSON body containing `extensions['sign-in-with-x']` with `domain`, `uri`, `version`, `chainId`, `type`, `nonce`, `issuedAt`.
209
- - **Discovery:** `authMode !== 'unprotected'` determines well-known visibility, not the protocol list. SIWX routes return 402 challenges and must be discoverable.
210
- - **OpenAPI:** Merge paths for multi-method endpoints (GET + DELETE on same path). Never overwrite.
211
- - **Duplicate route keys:** Registry silently overwrites (last-write-wins) with a dev-only `console.warn`. This is intentional — Next.js module loading order is non-deterministic during `next build`, so discovery stubs and real handlers may register the same key in either order. Prior art: ElysiaJS uses the identical pattern. See stablestudio `.claude/13_route-registry-dedup.md` for full research.
212
- - **Dynamic pricing (v0.3.1+):** Early body parsing with `request.clone()` enables accurate dynamic pricing. `maxPrice` is optional and acts as a safety net (cap + fallback). Body is parsed before 402 challenge generation when pricing function exists. See `.claude/15_router-dynamic-pricing-solution.md` for full design and derisking.
213
-
214
- ## Environment Variables
215
-
216
- ### Base URL
217
-
218
- `baseUrl` is **required** in `RouterConfig`. No auto-detection, no fallbacks. The realm is load-bearing for payment matching (MPP memo indexing, 402 challenge realm), so it must be explicitly set.
219
-
220
- ```typescript
221
- createRouter({
222
- baseUrl: process.env.BASE_URL!,
223
- // ...
224
- })
225
- ```
226
-
227
- If `baseUrl` is missing, `createRouter` throws immediately — in dev and prod. This ensures devs discover the issue on first `pnpm dev` rather than deploying with a wrong realm.
228
-
229
- ### CDP API Keys
230
-
231
- The router uses the default facilitator from `@coinbase/x402`, which requires CDP API keys in `process.env`:
232
-
233
- - `CDP_API_KEY_ID` — Coinbase Developer Platform API key ID
234
- - `CDP_API_KEY_SECRET` — CDP API key secret
235
-
236
- **Critical for Next.js apps with env validation (T3 stack, `@t3-oss/env-nextjs`):** These variables must be explicitly declared in your env schema. Next.js does not automatically expose all env vars to `process.env` — undeclared vars are invisible at runtime.
237
-
238
- ### MPP (Tempo) Environment Variables
239
-
240
- MPP payment verification requires an **authenticated** Tempo RPC endpoint. The public `https://rpc.tempo.xyz/` returns `401 Unauthorized`.
241
-
242
- - `TEMPO_RPC_URL` — Authenticated Tempo RPC URL (e.g. `https://user:pass@rpc.mainnet.tempo.xyz`)
243
-
244
- Alternatively, pass `rpcUrl` in the `mpp` config object to `createRouter()`. Without either, MPP on-chain verification fails with "unauthorized: authentication required".
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
-
278
- ### CDP Environment Variables
279
-
280
- Without these keys, the default facilitator cannot authenticate with CDP:
281
- - x402 server `initialize()` fails with "Failed to fetch supported kinds from facilitator: TypeError: fetch failed" or "Facilitator getSupported failed (401): Unauthorized"
282
- - All payment routes return empty 402 responses (no `PAYMENT-REQUIRED` header, no body)
283
-
284
- **Example env schema (T3/`@t3-oss/env-nextjs`):**
285
-
286
- ```typescript
287
- import { createEnv } from "@t3-oss/env-nextjs";
288
- import { z } from "zod";
289
-
290
- export const env = createEnv({
291
- server: {
292
- CDP_API_KEY_ID: z.string(),
293
- CDP_API_KEY_SECRET: z.string(),
294
- // ... other vars
295
- },
296
- runtimeEnv: {
297
- CDP_API_KEY_ID: process.env.CDP_API_KEY_ID,
298
- CDP_API_KEY_SECRET: process.env.CDP_API_KEY_SECRET,
299
- // ... other vars
300
- },
301
- });
302
- ```
303
-
304
- **Alternative:** Pass a custom facilitator config to `createRouter()` if you want to use a different facilitator URL or auth mechanism. But for most apps, the default CDP facilitator is correct.
305
-
306
- ## Version Stability
307
-
308
- The public API is **not stable**. Downstream consumers should pin exact versions (`"@agentcash/router": "0.2.0"`, not `"^0.2.0"`). Breaking changes will happen as we build out multi-protocol support and discover patterns across services. Semver will be respected once we hit 1.0.
309
-
310
- ## Build & Test
311
-
312
- ```bash
313
- pnpm build # tsup
314
- pnpm test # vitest
315
- pnpm typecheck # tsc --noEmit
316
- pnpm check # format + lint + typecheck + build + test
317
- ```
318
-
319
- ## Development Record
320
-
321
- The `.claude/` directory contains design docs, decision records, and bug analyses that document the reasoning behind the router's architecture. See `.claude/INDEX.md` for a table of contents.
322
-
323
- **Convention:** Every doc has a `Status` header. When you resolve work described in a doc, update its Status to `Resolved in vX.Y.Z` and update INDEX.md.
324
-
325
- ## Releasing
326
-
327
- This repo uses [changesets](https://github.com/changesets/changesets) for versioning and npm publishing.
328
-
329
- ### When doing work that should be released:
330
-
331
- 1. **Create a changeset** — Run `pnpm changeset` and describe the changes (patch/minor/major)
332
- 2. **Include the changeset file** in your PR (committed to `.changeset/`)
333
- 3. **Merge PR to main**
334
-
335
- ### What happens automatically:
336
-
337
- 1. When PRs with changesets merge to `main`, the `changesets/action` creates a **"chore: version packages"** PR that bumps `package.json` version and updates `CHANGELOG.md`
338
- 2. When that version PR is merged, the action **publishes to npm** automatically
339
-
340
- ### Troubleshooting
341
-
342
- - **Publish fails**: Check `NPM_TOKEN` secret is set and has write access to `@agentcash` scope
343
- - **No version PR created**: Ensure your PR included a `.changeset/*.md` file