@agentcash/router 1.11.0 → 1.13.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
@@ -31,7 +31,7 @@ src/
31
31
  kv-store/ one KvStore backs siwx nonce, siwx entitlement, mpp replay
32
32
  pricing/ fixed, tiered, dynamic (args-derived), upto-charge, metered-charge, format (atomic conversion)
33
33
  pipeline/ orchestrate.ts + steps/ + flows/ (paid → static-paid | dynamic-paid; siwx-only, api-key-only, unprotected)
34
- discovery/ well-known, openapi, llms-txt
34
+ discovery/ openapi, llms-txt, well-known (deprecated surface), not-found
35
35
  config/ RouterConfig + env schema (single source of truth), RouterConfigError, issue codes
36
36
  ```
37
37
 
@@ -39,17 +39,25 @@ src/
39
39
 
40
40
  `auth check -> body parse -> validate -> 402 challenge -> payment verify -> handler -> settle -> finalize`
41
41
 
42
+ The `body parse -> validate -> 402 challenge` prefix only holds when the challenge needs the body: args-derived/tiered pricing, `.validate()`, or a checkout session (`shouldParseBodyEarly`). On other paid routes a bare unpaid probe gets its 402 without the body being inspected (malformed body ⇒ 402, not 400); the paying retry parses and validates before verify/settle, so a 400 never charges the caller.
43
+
42
44
  Paid `.paid()` routes may set `mpp: { settleBeforeHandler: true }` (or chain `.mpp({ settleBeforeHandler: true })` on auto-priced routes) to broadcast MPP transaction (pull) credentials at verify instead of after the handler. x402 on the same route is unaffected. MPP hash (push) credentials already settle at verify. Use `.settlement({ onSettledHandlerError })` when eager MPP settle can charge before an upstream failure.
43
45
 
44
46
  ## Naming
45
47
 
46
- 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`.
48
+ Two constructor-verb families, split by audience:
49
+
50
+ - `create<Noun>` — public, top-level factories that return long-lived objects: `createRouter`, `createRouterFromEnv`, `createRequestHandler`, `createUptoChargeContext`.
51
+ - `build<Noun>` — internal, per-request assembly of protocol payloads and contexts: one verb, one domain noun, named 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`.
52
+
53
+ Pipeline steps use `run<Noun>` (executes and may answer the request), `resolve<Noun>` (computes a value), or `try<Noun>` (optional fast path returning null to continue).
47
54
 
48
55
  ## Critical rules
49
56
 
50
- - **Error handling.** Respect `.status` on any thrown error, not just `HttpError`. Pattern: `throw Object.assign(new Error('msg'), { status: 409 })`.
57
+ - **Error handling.** Respect `.status` on any thrown error, not just `HttpError`. Pattern: `throw Object.assign(new Error('msg'), { status: 409 })`. Three typed error classes, by phase: `RouterConfigError` (router construction), `RouteDefinitionError` (route registration, thrown at module-import time — never a JSON response), `HttpError` (request time, becomes a structured JSON response). New registration-time guards must throw `RouteDefinitionError`, not plain `Error`.
51
58
  - **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.
52
- - **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.
59
+ - **Discovery visibility.** `authMode !== 'unprotected'` determines discovery visibility (OpenAPI `x-payment-info` and the deprecated well-known listing), not the protocol list. SIWX routes are discoverable. All three pricing modes set `authMode = 'paid'`; the `billing` field (`'exact' | 'upto' | 'metered'`) distinguishes them downstream.
60
+ - **`.wellKnown()` is deprecated as a discovery recommendation.** The `/.well-known/x402` handler keeps working for legacy x402 clients (do not remove the logic), but never recommend it in docs, examples, or the 404 rediscovery hint — the recommended surfaces are `/openapi.json` and `/llms.txt`.
53
61
  - **OpenAPI.** Merge paths for multi-method endpoints (GET + DELETE on same path). Never overwrite.
54
62
  - **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.
55
63
  - **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.
@@ -58,6 +66,9 @@ Constructor-style functions use `build<Noun>` — one verb, one domain noun. Nam
58
66
  - **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`.
59
67
  - **MPP operator address.** Must equal `recipient` / payee. mppx's close handler asserts `sender === payee` on settle.
60
68
  - **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.
69
+ - **Builder invariants are enforced twice.** Every mutual-exclusion rule above is a compile-time `RouteError<'…'>` (via the `Ident`/`Bill` phantom generics in `builder.ts`) *and* a registration-time throw (for JS consumers). When adding or changing a rule, update both, plus the type tests in `tests/builder.test-d.ts` and the runtime tests in `tests/builder.test.ts`. Type tests run via vitest's typecheck pass (`pnpm test`), not `pnpm typecheck` — `tsconfig.json` excludes `tests/`.
70
+ - **`.method()` is discovery-only.** The exported const name (`GET`/`POST`) controls what Next.js serves; `.method()` controls what discovery output advertises. Default is `POST` (`GET` when `.query()` is used) — GET routes without `.query()` must chain `.method('GET')` or discovery advertises the wrong verb.
71
+ - **`X-Agent-Identity`.** Every 402 carries an optional DID-auth challenge header (`src/auth/agent-identity.ts`, `did-auth-challenge` package). Clients may return a signed proof; the verified DID surfaces as `ctx.actor`. Never gates a request; independent of SIWX and payment.
61
72
 
62
73
  ## Two entry points
63
74
 
package/README.md CHANGED
@@ -44,6 +44,8 @@ The recommended entry point reads its config from `process.env`. A copy-paste `.
44
44
  | `EVM_PAYEE_ADDRESS` | yes | EVM address that receives x402 and MPP payments (`0x…`, 20 bytes). Canonicalized to lowercase. The zero address is rejected. |
45
45
  | `CDP_API_KEY_ID`, `CDP_API_KEY_SECRET` | yes (EVM) | Coinbase Developer Platform credentials for the default EVM facilitator. Create API keys in the generous CDP free tier at https://portal.cdp.coinbase.com/projects/api-keys. T3 / `@t3-oss/env-nextjs` users must declare these in their env schema. |
46
46
 
47
+ > **Router construction phones the facilitator.** Creating the router kicks off a background fetch of the facilitator's supported payment kinds — including during `next build`. With missing or placeholder CDP keys you'll see `[x402] facilitator /supported failed, using hardcoded baseline: …` in build/dev logs. That's a graceful fallback, not a fatal error; paid routes still serve correct 402 challenges.
48
+
47
49
  ### Solana
48
50
 
49
51
  | Var | Required | Purpose |
@@ -61,6 +63,16 @@ The recommended entry point reads its config from `process.env`. A copy-paste `.
61
63
  | `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. |
62
64
  | `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`). |
63
65
 
66
+ > **MPP session mode needs the payee's private key.** Unlike x402 (address only), `.metered()` routes settle server-side, so `MPP_OPERATOR_KEY` must be the private key *of* `EVM_PAYEE_ADDRESS`. For local development, mint a throwaway keypair where the two line up:
67
+ >
68
+ > ```bash
69
+ > npm i -D viem # or pnpm add -D viem
70
+ > node -e "const {generatePrivateKey, privateKeyToAccount} = require('viem/accounts');
71
+ > const pk = generatePrivateKey();
72
+ > console.log('EVM_PAYEE_ADDRESS=' + privateKeyToAccount(pk).address);
73
+ > console.log('MPP_OPERATOR_KEY=' + pk);"
74
+ > ```
75
+
64
76
  ### Other
65
77
 
66
78
  | Var | Required | Purpose |
@@ -134,21 +146,32 @@ export const GET = router.route({ path: 'inbox/status' })
134
146
  // app/api/health/route.ts
135
147
  export const GET = router.route({ path: 'health' })
136
148
  .unprotected()
149
+ .method('GET')
137
150
  .handler(async () => ({ status: 'ok' }));
138
151
  ```
139
152
 
153
+ > **The exported const name and `.method()` are independent.** The name you export (`GET`/`POST`) controls which verb Next.js serves; `.method()` controls the verb advertised in discovery output (OpenAPI). The router defaults to `POST` (or `GET` when `.query()` is used) — so any other GET route must chain `.method('GET')` explicitly or discovery will advertise the wrong verb.
154
+
140
155
  ### 3. Auto-discovery
141
156
 
157
+ The router generates two recommended discovery surfaces. Mount both — agents look for each of them:
158
+
142
159
  ```typescript
143
- // app/openapi.json/route.ts
160
+ // app/openapi.json/route.ts — OpenAPI 3.1, served at GET <origin>/openapi.json
144
161
  import { router } from '@/lib/router';
145
162
  import '@/lib/routes-barrel'; // imports every route module so the registry is populated
146
163
  export const GET = router.openapi();
147
164
  ```
148
165
 
149
- 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.
166
+ ```typescript
167
+ // app/llms.txt/route.ts — plain-text agent guidance, served at GET <origin>/llms.txt
168
+ import { router } from '@/lib/router';
169
+ export const GET = router.llmsTxt();
170
+ ```
171
+
172
+ The barrel forces every route module to load before the discovery handlers walk the registry. Next.js otherwise lazy-loads route files on first hit, and unloaded routes don't appear in the spec (`llms.txt` renders from static config only, so it doesn't need the barrel).
150
173
 
151
- The `openapi.json` should be hosted at `GET <origin>/openapi.json`.
174
+ > **Deprecated:** `router.wellKnown()` (the `/.well-known/x402` resource listing) is no longer a recommended discovery surface. The handler still works if you already serve it — existing x402-native clients won't break — but new integrations should rely on `/openapi.json` and `/llms.txt`.
152
175
 
153
176
  ### 4. Unmatched route fallback
154
177
 
@@ -200,7 +223,18 @@ router.route({ path: 'inbox' })
200
223
 
201
224
  > **Gotcha:** serverless / multi-instance deployments must provide a real `kvStore` (Upstash / Vercel KV). Without one the entitlement is kept in a per-process `Map`, so a wallet that paid on instance A is treated as unpaid on instance B and the user gets charged again.
202
225
 
203
- ## Pricing
226
+ ## Reading a 402 challenge
227
+
228
+ What an unpaid request gets back depends on the route's auth mode:
229
+
230
+ - **Payment-only routes** (`.paid()`, `.upTo()`, `.metered()` without `.siwx()`) return a 402 with an **empty body**. The entire challenge — accepts, price, schema — is base64-encoded in the `PAYMENT-REQUIRED` response header (x402 v2), with MPP challenges in `WWW-Authenticate`. Don't `res.json()` these; decode the header.
231
+ - **SIWX routes** (`.siwx()`, alone or composed with a pricing mode) return a 402 with a **JSON body** that mirrors the `PAYMENT-REQUIRED` header, including the `sign-in-with-x` extension. Header and body are always identical.
232
+
233
+ Every 402 also carries an `X-Agent-Identity` response header: an optional [DID-auth challenge](https://www.npmjs.com/package/did-auth-challenge) (nonce, domain/route binding, expiry). Clients that hold a DID may sign it and send the proof back in `X-Agent-Identity` on the retry; the verified DID is then available to handlers as `ctx.actor`. It is fully optional — it never gates the request and is unrelated to SIWX or payment. Clients without a DID can ignore it.
234
+
235
+ ### When the body is validated
236
+
237
+ For args-derived (`.paid(fn)`) and tiered pricing, and for routes with `.validate()` or a checkout session, the body is parsed **before** the 402 challenge so the challenge can quote an accurate price (and `.validate()` can reject with its own status). On every other paid route, a bare unpaid probe gets its 402 **without the body being inspected** — a malformed body still yields 402, not 400. The paying retry then parses and validates the body *before* payment verification and settlement, so a 400 never costs the caller money.
204
238
 
205
239
  `.paid()`, `.upTo()`, and `.metered()` are mutually exclusive pricing modes: pick one per route.
206
240
 
@@ -233,7 +267,7 @@ router.route({ path: 'inbox' })
233
267
 
234
268
  ### `.upTo()`: handler-computed, x402 only
235
269
 
236
- 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).
270
+ Handler calls `charge(amount)` one or more times; the request settles once for the accumulated total, capped at `maxPrice`. A `charge()` that would push the running total past `maxPrice` throws a 400 `CHARGE_OVER_CAP` error (the request fails and nothing settles) — it is not silently clamped, so guard your loop if partial work should still be billed. Requires an `'upto'` accept on at least one configured x402 network (`createRouterFromEnv` auto-adds one on Base).
237
271
 
238
272
  ```typescript
239
273
  .upTo('0.05')
@@ -285,7 +319,7 @@ router.route({ path: 'domain/register' })
285
319
  .handler(async ({ body, wallet }) => registerDomain(body.domain, wallet));
286
320
  ```
287
321
 
288
- Pipeline order: `body parse -> validate -> 402 challenge -> payment -> handler`.
322
+ Pipeline order on these routes: `body parse -> validate -> 402 challenge -> payment -> handler`. (`.validate()` is one of the triggers for pre-challenge body parsing — see [When the body is validated](#when-the-body-is-validated).)
289
323
 
290
324
  ## Plugin Hooks
291
325