@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 +15 -4
- package/README.md +40 -6
- package/dist/index.cjs +269 -259
- package/dist/index.d.cts +92 -34
- package/dist/index.d.ts +92 -34
- package/dist/index.js +268 -259
- package/package.json +5 -5
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
|