@agentcash/router 0.4.8 → 0.4.10

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
@@ -10,7 +10,7 @@ Protocol-agnostic route framework for Next.js App Router APIs with x402 payment,
10
10
  4. **Observability is pluggable via RouterPlugin.** Zero boilerplate.
11
11
  5. **The package owns the x402 server lifecycle.** Init, verify, settle.
12
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/*` and `@coinbase/x402`.
13
+ 7. **Compose, don't reimplement.** Zero payment/auth protocol logic — delegates to `@x402/*`, `@coinbase/x402`, and `mppx`.
14
14
 
15
15
  ## Architecture
16
16
 
@@ -25,7 +25,7 @@ Protocol-agnostic route framework for Next.js App Router APIs with x402 payment,
25
25
  - `src/plugin.ts` — Plugin hook system
26
26
  - `src/server.ts` — x402 server initialization
27
27
  - `src/auth/` — Auth modules (siwx.ts, api-key.ts, nonce.ts)
28
- - `src/protocols/` — Protocol handlers (x402.ts, mpp.ts, detect.ts)
28
+ - `src/protocols/` — Protocol handlers (x402.ts, detect.ts). MPP is handled via `mppx` high-level API (`Mppx.create` in index.ts)
29
29
  - `src/discovery/` — Auto-generated endpoints (well-known.ts, openapi.ts)
30
30
 
31
31
  ## Auth Modes
@@ -187,43 +187,6 @@ pnpm typecheck # tsc --noEmit
187
187
  pnpm check # format + lint + typecheck + build + test
188
188
  ```
189
189
 
190
- ## Releasing
191
-
192
- **Release flow:** PR with version bump → merge → create GitHub Release → auto-publish to npm
193
-
194
- ### When doing work that should be released:
195
-
196
- 1. **Update `CHANGELOG.md`** — Add entry under new version heading with changes
197
- 2. **Bump version in `package.json`** — Match the changelog version
198
- 3. **Commit both** — e.g., `chore: bump to v0.6.0`
199
- 4. **Merge PR to main**
200
-
201
- ### To publish (human step):
202
-
203
- 1. Go to [GitHub Releases](https://github.com/Merit-Systems/agentcash-router/releases)
204
- 2. Click **Draft a new release**
205
- 3. Create tag: `v0.6.0` (must match package.json version)
206
- 4. Title: `v0.6.0`
207
- 5. Description: Copy from CHANGELOG.md or click "Generate release notes"
208
- 6. Click **Publish release**
209
-
210
- The `publish.yml` workflow will:
211
- - Run full test suite (`pnpm check`)
212
- - Verify package.json version matches tag
213
- - Publish to npm with `--access public`
214
-
215
- ### Version format
216
-
217
- - **Patch** (`0.5.1`): Bug fixes, docs, internal changes
218
- - **Minor** (`0.6.0`): New features, non-breaking additions
219
- - **Major** (`1.0.0`): Breaking changes (holding until API stabilizes)
220
-
221
- ### Troubleshooting
222
-
223
- - **Version mismatch error**: package.json version must exactly match the release tag (without `v` prefix)
224
- - **Publish fails**: Check `NPM_TOKEN` secret is set and has write access to `@agentcash` scope
225
- - **Tests fail**: Fix in a new PR, then re-create the release
226
-
227
190
  ## Development Record
228
191
 
229
192
  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.
@@ -31,7 +31,7 @@ Every paid API route in a Merit Systems service shared the same ~80-150 lines of
31
31
 
32
32
  7. **Self-registering routes + validated barrel (Approach B).** Routes self-register via `.handler()` at import time. A barrel file imports all route modules. Discovery endpoints (`.wellKnown()`, `.openapi()`) validate that the barrel is complete by comparing registered routes against the `prices` map. For routes in separate handler files (e.g., Next.js `route.ts` files), use **discovery stubs** — lightweight registrations that provide metadata for discovery without the real handler. Guard stubs with `registry.has()` to avoid unnecessary overwrites.
33
33
 
34
- 8. **Both x402 and MPP ship from day one.** Dual-protocol support is not an afterthought. Routes declare `protocols: ['x402', 'mpp']` and the orchestration layer routes to the correct handler based on the request header. MPP uses low-level `mppx` primitives (`Challenge`, `Credential`, `tempo.charge`) not the high-level `Mppx.create()` wrapper because the router owns orchestration.
34
+ 8. **Both x402 and MPP ship from day one.** Dual-protocol support is not an afterthought. Routes declare `protocols: ['x402', 'mpp']` and the orchestration layer routes to the correct handler based on the request header. MPP uses `mppx`'s high-level `Mppx.create()` API — the router creates an instance at init time and calls `mppx.charge({ amount })(request)` at request time. This returns either a 402 challenge or a 200 with `withReceipt()` for attaching the payment receipt header.
35
35
 
36
36
  9. **`zod-openapi` for OpenAPI 3.1.** Zod schemas are the single source of truth for request/response types. OpenAPI docs are auto-generated from them. No manual spec maintenance.
37
37
 
@@ -80,7 +80,7 @@ Request in
80
80
  → protocol verify (x402 or MPP)
81
81
  → plugin.onPaymentVerified()
82
82
  → handler(ctx) → result
83
- → if status < 400: settle payment (x402) or receipt (MPP)
83
+ → if status < 400: settle payment (x402) or withReceipt (MPP)
84
84
  → if provider configured: fireProviderQuota()
85
85
  → plugin.onResponse()
86
86
  Response out
@@ -101,7 +101,6 @@ Response out
101
101
  | `src/registry.ts` | `RouteRegistry` with barrel validation |
102
102
  | `src/protocols/detect.ts` | Header-based protocol detection |
103
103
  | `src/protocols/x402.ts` | x402 challenge/verify/settle wrappers |
104
- | `src/protocols/mpp.ts` | MPP challenge/verify/receipt wrappers (uses mppx low-level primitives) |
105
104
  | `src/server.ts` | x402 server initialization with retry |
106
105
  | `src/auth/siwx.ts` | SIWX verification |
107
106
  | `src/auth/api-key.ts` | API key verification |
@@ -159,7 +158,7 @@ TEMPO_RPC_URL=https://user:pass@rpc.mainnet.tempo.xyz # Authenticated Tempo RPC
159
158
 
160
159
  **Tempo RPC requires authentication.** The default `rpc.tempo.xyz` returns 401. Get credentials from the Tempo team. The `rpcUrl` can be set in config or via `TEMPO_RPC_URL` env var (config takes precedence).
161
160
 
162
- **Peer dependencies for MPP:** `mppx` is an optional peer dep. It has `viem` as a peer dependency. Both are required for MPP support.
161
+ **Peer dependency for MPP:** `mppx` is an optional peer dep. Required only when `protocols` includes `'mpp'`.
163
162
 
164
163
  ## Creating Routes
165
164
 
@@ -415,21 +414,26 @@ The type system (generic parameters `HasAuth`, `NeedsBody`, `HasBody`) prevents
415
414
  - `.siwx()` is mutually exclusive with `.paid()`
416
415
  - `.apiKey()` CAN compose with `.paid()`
417
416
 
418
- ## MPP Internals (Critical Pitfalls)
417
+ ## MPP Internals
419
418
 
420
- The router uses mppx's **low-level primitives**, not the high-level `Mppx.create()` API. This matters because mppx's internals have subtle conventions:
419
+ The router uses `mppx`'s high-level `Mppx.create()` API, which encapsulates the entire challenge-credential-receipt lifecycle.
421
420
 
422
- 1. **NextRequest vs Request.** `Credential.fromRequest()` breaks with Next.js `NextRequest` due to subtle header handling differences. The router converts via `toStandardRequest()` — creating a new standard `Request` with the same URL, method, headers, and body.
421
+ ### How it works
423
422
 
424
- 2. **`Challenge.fromIntent()` takes payment data, not an HTTP Request.** The `request` field in `fromIntent()` is the payment request object (`{ amount, currency, recipient, decimals }`), NOT the HTTP Request. Passing the wrong object causes silent challenge generation failures.
423
+ 1. **Init time** (`src/index.ts`): `Mppx.create({ methods: [tempo.charge({ currency, recipient, rpcUrl })], secretKey })` creates a server instance. This is done inside the async `initPromise` IIFE alongside x402 server init.
425
424
 
426
- 3. **`tempo.charge().verify()` returns a receipt, not `{ valid, payer }`.** On success it returns `{ method, status, reference, timestamp }`. On failure it throws. Check `receipt.status === 'success'`, not `receipt.valid`.
425
+ 2. **Challenge** (`build402` in orchestrate.ts): `deps.mppx.charge({ amount })(request)` returns `{ status: 402, challenge: Response }`. The `WWW-Authenticate` header is extracted from the challenge `Response` and set on the router's 402 response.
427
426
 
428
- 4. **`tempo.charge()` constructor takes operational config only.** In mppx, `currency` and `recipient` are NOT passed to `tempo.charge()`. They are passed in the `request` object to `Challenge.fromIntent()` and `verify()`. Only `getClient`, `decimals`, `feePayer`, `testnet`, etc. go in the constructor.
427
+ 3. **Verify + Receipt** (MPP section in orchestrate.ts): `deps.mppx.charge({ amount })(request)` returns `{ status: 200, withReceipt }` when the credential is valid. `withReceipt(response)` returns a new `Response` with the `Payment-Receipt` header attached.
429
428
 
430
- 5. **Tempo RPC requires authentication.** The default `rpc.tempo.xyz` returns 401. Always provide `rpcUrl` or set `TEMPO_RPC_URL` env var with authenticated credentials.
429
+ 4. **Wallet extraction**: `Credential.fromRequest(request)` (from `mppx` core) extracts the credential, and `credential.source` contains the payer's DID.
431
430
 
432
- 6. **viem is a peer dep of mppx.** The router uses viem directly for `createClient` and `http` in `buildGetClient()`. viem is always available when mppx is installed as a peer dep.
431
+ ### Key details
432
+
433
+ - **`withReceipt()` creates a new Response** — it does not mutate the original. The returned value must be used (cast to `NextResponse`).
434
+ - **`Credential.fromRequest()` is the only low-level mppx import** — used solely for wallet extraction after mppx has already verified the payment.
435
+ - **`mppx` is an optional peer dep** — routes using `protocols: ['mpp']` require it. The router's `OrchestrateDeps.mppx` is `null` when not configured.
436
+ - **Tempo RPC requires authentication.** The default `rpc.tempo.xyz` returns 401. Always provide `rpcUrl` or set `TEMPO_RPC_URL` env var with authenticated credentials.
433
437
 
434
438
  ## Registration-Time Validation
435
439
 
@@ -497,9 +501,6 @@ Barrel validation catches mismatches: keys in `prices` but not registered → er
497
501
  | Route not in discovery docs | Missing barrel import | Import the route file in your barrel |
498
502
  | Settlement not happening | Handler returned status >= 400 | Settlement is gated on success responses |
499
503
  | MPP 401 `unauthorized: authentication required` | Using default unauthenticated Tempo RPC | Set `TEMPO_RPC_URL` env var or `mpp.rpcUrl` config with authenticated URL |
500
- | MPP `Credential.fromRequest()` returns undefined | NextRequest header handling incompatibility | Router handles this via `toStandardRequest()` — if you see this, the router dist is stale |
501
- | MPP verify returns `status: 'success'` but route returns 402 | Code checking `.valid` instead of `.status` | Verify returns a receipt `{ status, reference }`, not `{ valid, payer }` |
502
- | MPP using wrong RPC URL after rebuild | Next.js webpack cache or stale pnpm link | Delete `.next/`, run `pnpm install` in the app to pick up new router dist |
503
504
  | `route 'X' in prices map but not registered` | Discovery endpoint hit before route module loaded | Add barrel import to discovery route files |
504
505
  | `mppx package is required` | mppx not installed | `pnpm add mppx` — it's an optional peer dep |
505
506
 
package/dist/index.cjs CHANGED
@@ -123,7 +123,7 @@ var RouteRegistry = class {
123
123
  };
124
124
 
125
125
  // src/orchestrate.ts
126
- var import_server3 = require("next/server");
126
+ var import_server2 = require("next/server");
127
127
 
128
128
  // src/plugin.ts
129
129
  function createDefaultContext(meta) {
@@ -366,6 +366,9 @@ function resolveMaxPrice(pricing) {
366
366
  return max;
367
367
  }
368
368
 
369
+ // src/orchestrate.ts
370
+ var import_mppx = require("mppx");
371
+
369
372
  // src/protocols/x402.ts
370
373
  async function buildX402Challenge(server, routeEntry, request, price, payeeAddress, network, extensions) {
371
374
  const { encodePaymentRequiredHeader } = await import("@x402/core/http");
@@ -431,107 +434,6 @@ async function settleX402Payment(server, payload, requirements) {
431
434
  return { encoded, result };
432
435
  }
433
436
 
434
- // src/protocols/mpp.ts
435
- var import_mppx = require("mppx");
436
- var import_server2 = require("mppx/server");
437
- var import_tempo = require("mppx/tempo");
438
- var import_viem = require("viem");
439
- var import_chains = require("viem/chains");
440
- function buildGetClient(rpcUrl) {
441
- const url = rpcUrl ?? process.env.TEMPO_RPC_URL;
442
- if (!url) return {};
443
- return {
444
- getClient: () => (0, import_viem.createClient)({ chain: import_chains.tempo, transport: (0, import_viem.http)(url) })
445
- };
446
- }
447
- function toStandardRequest(request) {
448
- if (request.constructor.name === "Request") {
449
- return request;
450
- }
451
- return new Request(request.url, {
452
- method: request.method,
453
- headers: request.headers
454
- });
455
- }
456
- var DEFAULT_DECIMALS = 6;
457
- async function buildMPPChallenge(routeEntry, request, mppConfig, price) {
458
- const standardRequest = toStandardRequest(request);
459
- const currency = mppConfig.currency;
460
- const recipient = mppConfig.recipient ?? "";
461
- const challenge = import_mppx.Challenge.fromMethod(import_tempo.Methods.charge, {
462
- secretKey: mppConfig.secretKey,
463
- realm: new URL(standardRequest.url).origin,
464
- request: {
465
- amount: price,
466
- currency,
467
- recipient,
468
- decimals: DEFAULT_DECIMALS
469
- }
470
- });
471
- return import_mppx.Challenge.serialize(challenge);
472
- }
473
- async function verifyMPPCredential(request, _routeEntry, mppConfig, price) {
474
- const standardRequest = toStandardRequest(request);
475
- const currency = mppConfig.currency;
476
- const recipient = mppConfig.recipient ?? "";
477
- try {
478
- const authHeader = standardRequest.headers.get("Authorization");
479
- if (!authHeader) {
480
- console.error("[MPP] No Authorization header found");
481
- return null;
482
- }
483
- const credential = import_mppx.Credential.fromRequest(standardRequest);
484
- if (!credential?.challenge) {
485
- console.error("[MPP] Invalid credential structure");
486
- return null;
487
- }
488
- const isValid = import_mppx.Challenge.verify(credential.challenge, { secretKey: mppConfig.secretKey });
489
- if (!isValid) {
490
- console.error("[MPP] Challenge HMAC verification failed");
491
- return { valid: false, payer: null };
492
- }
493
- const methodIntent = import_server2.tempo.charge({
494
- ...buildGetClient(mppConfig.rpcUrl)
495
- });
496
- const paymentRequest = {
497
- amount: price,
498
- currency,
499
- recipient,
500
- decimals: DEFAULT_DECIMALS
501
- };
502
- const receipt = await methodIntent.verify({
503
- credential,
504
- request: paymentRequest
505
- });
506
- if (!receipt || receipt.status !== "success") {
507
- console.error("[MPP] Tempo verification failed:", receipt);
508
- return { valid: false, payer: null };
509
- }
510
- const payer = receipt.reference ?? "";
511
- return {
512
- valid: true,
513
- payer,
514
- txHash: receipt.reference
515
- };
516
- } catch (error) {
517
- console.error("[MPP] Credential verification error:", {
518
- message: error instanceof Error ? error.message : String(error),
519
- stack: error instanceof Error ? error.stack : void 0,
520
- errorType: error?.constructor?.name
521
- });
522
- return null;
523
- }
524
- }
525
- function buildMPPReceipt(reference) {
526
- const receipt = import_mppx.Receipt.from({
527
- method: "tempo",
528
- status: "success",
529
- reference,
530
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
531
- });
532
- return import_mppx.Receipt.serialize(receipt);
533
- }
534
-
535
437
  // src/auth/siwx.ts
536
438
  var SIWX_ERROR_MESSAGES = {
537
439
  siwx_missing_header: "Missing SIGN-IN-WITH-X header",
@@ -633,7 +535,7 @@ function createRequestHandler(routeEntry, handler, deps) {
633
535
  firePluginResponse(deps, pluginCtx, meta, response);
634
536
  }
635
537
  function fail(status, message, meta, pluginCtx) {
636
- const response = import_server3.NextResponse.json({ success: false, error: message }, { status });
538
+ const response = import_server2.NextResponse.json({ success: false, error: message }, { status });
637
539
  firePluginResponse(deps, pluginCtx, meta, response);
638
540
  return response;
639
541
  }
@@ -692,7 +594,8 @@ function createRequestHandler(routeEntry, handler, deps) {
692
594
  }
693
595
  const protocol = detectProtocol(request);
694
596
  let earlyBodyData;
695
- const needsEarlyParse = !protocol && routeEntry.bodySchema && (typeof routeEntry.pricing === "function" || routeEntry.validateFn);
597
+ const pricingNeedsBody = routeEntry.pricing != null && typeof routeEntry.pricing !== "string";
598
+ const needsEarlyParse = !protocol && routeEntry.bodySchema && (pricingNeedsBody || routeEntry.validateFn);
696
599
  if (needsEarlyParse) {
697
600
  const requestForPricing = request.clone();
698
601
  const earlyBodyResult = await parseBody(requestForPricing, routeEntry);
@@ -775,7 +678,7 @@ function createRequestHandler(routeEntry, handler, deps) {
775
678
  route: routeEntry.key
776
679
  });
777
680
  }
778
- const response = new import_server3.NextResponse(JSON.stringify(paymentRequired), {
681
+ const response = new import_server2.NextResponse(JSON.stringify(paymentRequired), {
779
682
  status: 402,
780
683
  headers: { "Content-Type": "application/json" }
781
684
  });
@@ -785,7 +688,7 @@ function createRequestHandler(routeEntry, handler, deps) {
785
688
  }
786
689
  const siwx = await verifySIWX(request, routeEntry, deps.nonceStore);
787
690
  if (!siwx.valid) {
788
- const response = import_server3.NextResponse.json(
691
+ const response = import_server2.NextResponse.json(
789
692
  { error: siwx.code, message: SIWX_ERROR_MESSAGES[siwx.code] },
790
693
  { status: 402 }
791
694
  );
@@ -802,6 +705,21 @@ function createRequestHandler(routeEntry, handler, deps) {
802
705
  return handleAuth(wallet, void 0);
803
706
  }
804
707
  if (!protocol || protocol === "siwx") {
708
+ if (routeEntry.pricing) {
709
+ const initErrors = routeEntry.protocols.map((p) => {
710
+ if (p === "x402" && deps.x402InitError) return `x402: ${deps.x402InitError}`;
711
+ if (p === "mpp" && deps.mppInitError) return `mpp: ${deps.mppInitError}`;
712
+ return null;
713
+ }).filter(Boolean);
714
+ if (initErrors.length > 0) {
715
+ return fail(
716
+ 500,
717
+ `Payment protocol initialization failed. ${initErrors.join("; ")}`,
718
+ meta,
719
+ pluginCtx
720
+ );
721
+ }
722
+ }
805
723
  return await build402(request, routeEntry, deps, meta, pluginCtx, earlyBodyData);
806
724
  }
807
725
  const body = await parseBody(request, routeEntry);
@@ -829,9 +747,22 @@ function createRequestHandler(routeEntry, handler, deps) {
829
747
  pluginCtx
830
748
  );
831
749
  }
750
+ if (!routeEntry.protocols.includes(protocol)) {
751
+ const accepted = routeEntry.protocols.join(", ") || "none";
752
+ console.warn(
753
+ `[router] ${routeEntry.key}: received ${protocol} payment but route accepts [${accepted}]`
754
+ );
755
+ return fail(
756
+ 400,
757
+ `This route does not accept ${protocol} payments. Accepted protocols: ${accepted}`,
758
+ meta,
759
+ pluginCtx
760
+ );
761
+ }
832
762
  if (protocol === "x402") {
833
763
  if (!deps.x402Server) {
834
764
  const reason = deps.x402InitError ? `x402 facilitator initialization failed: ${deps.x402InitError}` : "x402 server not initialized \u2014 ensure @x402/core, @x402/evm, and @coinbase/x402 are installed";
765
+ console.error(`[router] ${routeEntry.key}: ${reason}`);
835
766
  return fail(500, reason, meta, pluginCtx);
836
767
  }
837
768
  const verify = await verifyX402Payment(
@@ -904,10 +835,37 @@ function createRequestHandler(routeEntry, handler, deps) {
904
835
  return response;
905
836
  }
906
837
  if (protocol === "mpp") {
907
- if (!deps.mppConfig) return await build402(request, routeEntry, deps, meta, pluginCtx);
908
- const verify = await verifyMPPCredential(request, routeEntry, deps.mppConfig, price);
909
- if (!verify?.valid) return await build402(request, routeEntry, deps, meta, pluginCtx);
910
- const wallet = verify.payer.toLowerCase();
838
+ if (!deps.mppx) {
839
+ const reason = deps.mppInitError ? `MPP initialization failed: ${deps.mppInitError}` : "MPP not initialized \u2014 ensure mppx is installed and mpp config (secretKey, currency, recipient) is correct";
840
+ console.error(`[router] ${routeEntry.key}: ${reason}`);
841
+ return fail(500, reason, meta, pluginCtx);
842
+ }
843
+ let mppResult;
844
+ try {
845
+ mppResult = await deps.mppx.charge({ amount: price })(request);
846
+ } catch (err) {
847
+ const message = err instanceof Error ? err.message : String(err);
848
+ console.error(`[router] ${routeEntry.key}: MPP charge failed: ${message}`);
849
+ firePluginHook(deps.plugin, "onAlert", pluginCtx, {
850
+ level: "critical",
851
+ message: `MPP charge failed: ${message}`,
852
+ route: routeEntry.key
853
+ });
854
+ return fail(500, `MPP payment processing failed: ${message}`, meta, pluginCtx);
855
+ }
856
+ if (mppResult.status === 402) {
857
+ console.warn(
858
+ `[router] ${routeEntry.key}: MPP credential present but charge() returned 402 \u2014 credential may be invalid, or check TEMPO_RPC_URL configuration`
859
+ );
860
+ firePluginHook(deps.plugin, "onAlert", pluginCtx, {
861
+ level: "warn",
862
+ message: "MPP payment rejected despite credential present \u2014 possible config issue (TEMPO_RPC_URL)",
863
+ route: routeEntry.key
864
+ });
865
+ return await build402(request, routeEntry, deps, meta, pluginCtx);
866
+ }
867
+ const credential = import_mppx.Credential.fromRequest(request);
868
+ const wallet = (credential?.source ?? "").toLowerCase();
911
869
  pluginCtx.setVerifiedWallet(wallet);
912
870
  firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
913
871
  protocol: "mpp",
@@ -924,10 +882,9 @@ function createRequestHandler(routeEntry, handler, deps) {
924
882
  body.data
925
883
  );
926
884
  if (response.status < 400) {
927
- try {
928
- response.headers.set("Payment-Receipt", await buildMPPReceipt(crypto.randomUUID()));
929
- } catch {
930
- }
885
+ const receiptResponse = mppResult.withReceipt(response);
886
+ finalize(receiptResponse, rawResult, meta, pluginCtx);
887
+ return receiptResponse;
931
888
  }
932
889
  finalize(response, rawResult, meta, pluginCtx);
933
890
  return response;
@@ -942,7 +899,7 @@ async function parseBody(request, routeEntry) {
942
899
  if (result.success) return { ok: true, data: result.data };
943
900
  return {
944
901
  ok: false,
945
- response: import_server3.NextResponse.json(
902
+ response: import_server2.NextResponse.json(
946
903
  { success: false, error: result.error, issues: result.issues },
947
904
  { status: 400 }
948
905
  )
@@ -1001,7 +958,7 @@ async function resolveDynamicPrice(bodyData, routeEntry, deps, pluginCtx, meta)
1001
958
  });
1002
959
  return { price: routeEntry.maxPrice };
1003
960
  } else {
1004
- const errorResponse = import_server3.NextResponse.json(
961
+ const errorResponse = import_server2.NextResponse.json(
1005
962
  { success: false, error: "Price calculation failed" },
1006
963
  { status: 500 }
1007
964
  );
@@ -1011,14 +968,14 @@ async function resolveDynamicPrice(bodyData, routeEntry, deps, pluginCtx, meta)
1011
968
  }
1012
969
  }
1013
970
  async function build402(request, routeEntry, deps, meta, pluginCtx, bodyData) {
1014
- const response = new import_server3.NextResponse(null, {
971
+ const response = new import_server2.NextResponse(null, {
1015
972
  status: 402,
1016
973
  headers: {
1017
974
  "Content-Type": "application/json"
1018
975
  }
1019
976
  });
1020
977
  let challengePrice;
1021
- if (bodyData !== void 0 && typeof routeEntry.pricing === "function") {
978
+ if (bodyData !== void 0 && typeof routeEntry.pricing !== "string" && routeEntry.pricing != null) {
1022
979
  const result = await resolveDynamicPrice(bodyData, routeEntry, deps, pluginCtx, meta);
1023
980
  if ("error" in result) return result.error;
1024
981
  challengePrice = result.price;
@@ -1069,12 +1026,13 @@ async function build402(request, routeEntry, deps, meta, pluginCtx, bodyData) {
1069
1026
  });
1070
1027
  }
1071
1028
  }
1072
- if (routeEntry.protocols.includes("mpp") && deps.mppConfig) {
1029
+ if (routeEntry.protocols.includes("mpp") && deps.mppx) {
1073
1030
  try {
1074
- response.headers.set(
1075
- "WWW-Authenticate",
1076
- await buildMPPChallenge(routeEntry, request, deps.mppConfig, challengePrice)
1077
- );
1031
+ const result = await deps.mppx.charge({ amount: challengePrice })(request);
1032
+ if (result.status === 402) {
1033
+ const wwwAuth = result.challenge.headers.get("WWW-Authenticate");
1034
+ if (wwwAuth) response.headers.set("WWW-Authenticate", wwwAuth);
1035
+ }
1078
1036
  } catch (err) {
1079
1037
  firePluginHook(deps.plugin, "onAlert", pluginCtx, {
1080
1038
  level: "critical",
@@ -1341,7 +1299,7 @@ var RouteBuilder = class {
1341
1299
  };
1342
1300
 
1343
1301
  // src/discovery/well-known.ts
1344
- var import_server4 = require("next/server");
1302
+ var import_server3 = require("next/server");
1345
1303
  function createWellKnownHandler(registry, baseUrl, pricesKeys, options = {}) {
1346
1304
  let validated = false;
1347
1305
  return async (_request) => {
@@ -1379,7 +1337,7 @@ function createWellKnownHandler(registry, baseUrl, pricesKeys, options = {}) {
1379
1337
  if (instructions) {
1380
1338
  body.instructions = instructions;
1381
1339
  }
1382
- return import_server4.NextResponse.json(body, {
1340
+ return import_server3.NextResponse.json(body, {
1383
1341
  headers: {
1384
1342
  "Access-Control-Allow-Origin": "*",
1385
1343
  "Access-Control-Allow-Methods": "GET",
@@ -1390,12 +1348,12 @@ function createWellKnownHandler(registry, baseUrl, pricesKeys, options = {}) {
1390
1348
  }
1391
1349
 
1392
1350
  // src/discovery/openapi.ts
1393
- var import_server5 = require("next/server");
1351
+ var import_server4 = require("next/server");
1394
1352
  function createOpenAPIHandler(registry, baseUrl, pricesKeys, options) {
1395
1353
  let cached = null;
1396
1354
  let validated = false;
1397
1355
  return async (_request) => {
1398
- if (cached) return import_server5.NextResponse.json(cached);
1356
+ if (cached) return import_server4.NextResponse.json(cached);
1399
1357
  if (!validated && pricesKeys) {
1400
1358
  registry.validate(pricesKeys);
1401
1359
  validated = true;
@@ -1422,7 +1380,7 @@ function createOpenAPIHandler(registry, baseUrl, pricesKeys, options) {
1422
1380
  tags: Array.from(tagSet).sort().map((name) => ({ name })),
1423
1381
  paths
1424
1382
  });
1425
- return import_server5.NextResponse.json(cached);
1383
+ return import_server4.NextResponse.json(cached);
1426
1384
  };
1427
1385
  }
1428
1386
  function deriveTag(routeKey) {
@@ -1491,21 +1449,30 @@ function createRouter(config) {
1491
1449
  const nonceStore = config.siwx?.nonceStore ?? new MemoryNonceStore();
1492
1450
  const network = config.network ?? "eip155:8453";
1493
1451
  const baseUrl = typeof globalThis.process !== "undefined" ? process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000" : "http://localhost:3000";
1494
- if (config.protocols) {
1495
- if (config.protocols.length === 0) {
1496
- throw new Error(
1497
- "RouterConfig.protocols cannot be empty. Omit the field to use default ['x402'] or specify protocols explicitly."
1498
- );
1499
- }
1500
- if (config.protocols.includes("mpp") && !config.mpp) {
1501
- throw new Error(
1502
- 'RouterConfig.protocols includes "mpp" but RouterConfig.mpp is not configured. Add mpp: { secretKey, currency, recipient } to your router config.'
1503
- );
1452
+ if (config.protocols && config.protocols.length === 0) {
1453
+ throw new Error(
1454
+ "RouterConfig.protocols cannot be empty. Omit the field to use default ['x402'] or specify protocols explicitly."
1455
+ );
1456
+ }
1457
+ let x402ConfigError;
1458
+ let mppConfigError;
1459
+ if ((!config.protocols || config.protocols.includes("x402")) && !config.payeeAddress) {
1460
+ x402ConfigError = "x402 requires payeeAddress in router config.";
1461
+ }
1462
+ if (config.protocols?.includes("mpp")) {
1463
+ if (!config.mpp) {
1464
+ mppConfigError = 'protocols includes "mpp" but mpp config is missing. Add mpp: { secretKey, currency, recipient } to your router config.';
1465
+ } else if (!config.mpp.recipient && !config.payeeAddress) {
1466
+ mppConfigError = "MPP requires a recipient address. Set mpp.recipient or payeeAddress in your router config.";
1467
+ } else if (!(config.mpp.rpcUrl ?? process.env.TEMPO_RPC_URL)) {
1468
+ mppConfigError = "MPP requires an authenticated Tempo RPC URL. Set TEMPO_RPC_URL env var or pass rpcUrl in the mpp config object.";
1504
1469
  }
1505
- if (config.protocols.includes("x402") && !config.payeeAddress) {
1506
- throw new Error(
1507
- 'RouterConfig.protocols includes "x402" but RouterConfig.payeeAddress is not configured.'
1508
- );
1470
+ }
1471
+ const allConfigErrors = [x402ConfigError, mppConfigError].filter(Boolean);
1472
+ if (allConfigErrors.length > 0) {
1473
+ for (const err of allConfigErrors) console.error(`[router] ${err}`);
1474
+ if (process.env.NODE_ENV === "production") {
1475
+ throw new Error(allConfigErrors.join("\n"));
1509
1476
  }
1510
1477
  }
1511
1478
  if (config.plugin?.init) {
@@ -1525,17 +1492,47 @@ function createRouter(config) {
1525
1492
  nonceStore,
1526
1493
  payeeAddress: config.payeeAddress,
1527
1494
  network,
1528
- mppConfig: config.mpp
1495
+ mppx: null
1529
1496
  };
1530
1497
  deps.initPromise = (async () => {
1531
- try {
1532
- const { createX402Server: createX402Server2 } = await Promise.resolve().then(() => (init_server(), server_exports));
1533
- const result = await createX402Server2(config);
1534
- deps.x402Server = result.server;
1535
- await result.initPromise;
1536
- } catch (err) {
1537
- deps.x402Server = null;
1538
- deps.x402InitError = err instanceof Error ? err.message : String(err);
1498
+ if (x402ConfigError) {
1499
+ deps.x402InitError = x402ConfigError;
1500
+ } else {
1501
+ try {
1502
+ const { createX402Server: createX402Server2 } = await Promise.resolve().then(() => (init_server(), server_exports));
1503
+ const result = await createX402Server2(config);
1504
+ deps.x402Server = result.server;
1505
+ await result.initPromise;
1506
+ } catch (err) {
1507
+ deps.x402Server = null;
1508
+ deps.x402InitError = err instanceof Error ? err.message : String(err);
1509
+ }
1510
+ }
1511
+ if (mppConfigError) {
1512
+ deps.mppInitError = mppConfigError;
1513
+ } else if (config.mpp) {
1514
+ try {
1515
+ const { Mppx, tempo } = await import("mppx/server");
1516
+ const rpcUrl = config.mpp.rpcUrl ?? process.env.TEMPO_RPC_URL;
1517
+ deps.mppx = Mppx.create({
1518
+ methods: [
1519
+ tempo.charge({
1520
+ currency: config.mpp.currency,
1521
+ recipient: config.mpp.recipient ?? config.payeeAddress,
1522
+ getClient: async () => {
1523
+ const { createClient, http } = await import("viem");
1524
+ const { tempo: tempoChain } = await import("viem/chains");
1525
+ return createClient({ chain: tempoChain, transport: http(rpcUrl) });
1526
+ }
1527
+ })
1528
+ ],
1529
+ secretKey: config.mpp.secretKey
1530
+ });
1531
+ } catch (err) {
1532
+ deps.mppx = null;
1533
+ deps.mppInitError = err instanceof Error ? err.message : String(err);
1534
+ console.error(`[router] MPP initialization failed: ${deps.mppInitError}`);
1535
+ }
1539
1536
  }
1540
1537
  })();
1541
1538
  const pricesKeys = config.prices ? Object.keys(config.prices) : void 0;
package/dist/index.d.cts CHANGED
@@ -288,16 +288,22 @@ interface OrchestrateDeps {
288
288
  x402Server: X402Server | null;
289
289
  initPromise: Promise<void>;
290
290
  x402InitError?: string;
291
+ mppInitError?: string;
291
292
  plugin?: RouterPlugin;
292
293
  nonceStore: NonceStore;
293
294
  payeeAddress: string;
294
295
  network: string;
295
- mppConfig?: {
296
- secretKey: string;
297
- currency: string;
298
- recipient?: string;
299
- rpcUrl?: string;
300
- };
296
+ mppx?: {
297
+ charge: (options: {
298
+ amount: string;
299
+ }) => (input: Request) => Promise<{
300
+ status: 402;
301
+ challenge: Response;
302
+ } | {
303
+ status: 200;
304
+ withReceipt: (response: Response) => Response;
305
+ }>;
306
+ } | null;
301
307
  }
302
308
 
303
309
  type True = true;
@@ -381,13 +387,15 @@ interface MonitorEntry {
381
387
  warn?: number;
382
388
  critical?: number;
383
389
  }
384
- interface ServiceRouter {
385
- route(key: string): RouteBuilder;
390
+ interface ServiceRouter<TPriceKeys extends string = never> {
391
+ route<K extends string>(key: K): [K] extends [TPriceKeys] ? RouteBuilder<undefined, undefined, true, false, false> : RouteBuilder<undefined, undefined, false, false, false>;
386
392
  wellKnown(options?: WellKnownOptions): (request: NextRequest) => Promise<NextResponse>;
387
393
  openapi(options: OpenAPIOptions): (request: NextRequest) => Promise<NextResponse>;
388
394
  monitors(): MonitorEntry[];
389
395
  registry: RouteRegistry;
390
396
  }
391
- declare function createRouter(config: RouterConfig): ServiceRouter;
397
+ declare function createRouter<const P extends Record<string, string> = Record<string, never>>(config: RouterConfig & {
398
+ prices?: P;
399
+ }): ServiceRouter<Extract<keyof P, string>>;
392
400
 
393
401
  export { type AlertEvent, type AlertFn, type AlertLevel, type AuthEvent, type AuthMode, type ErrorEvent, type HandlerContext, HttpError, MemoryNonceStore, type MonitorEntry, type NonceStore, type OveragePolicy, type PaidOptions, type PaymentEvent, type PluginContext, type PricingConfig, type ProtocolType, type ProviderConfig, type ProviderQuotaEvent, type QuotaInfo, type QuotaLevel, type RedisNonceStoreOptions, type RequestMeta, type ResponseMeta, RouteBuilder, type RouteEntry, RouteRegistry, type RouterConfig, type RouterPlugin, SIWX_CHALLENGE_EXPIRY_MS, type ServiceRouter, type SettlementEvent, type TierConfig, type X402Server, consolePlugin, createRedisNonceStore, createRouter };
package/dist/index.d.ts CHANGED
@@ -288,16 +288,22 @@ interface OrchestrateDeps {
288
288
  x402Server: X402Server | null;
289
289
  initPromise: Promise<void>;
290
290
  x402InitError?: string;
291
+ mppInitError?: string;
291
292
  plugin?: RouterPlugin;
292
293
  nonceStore: NonceStore;
293
294
  payeeAddress: string;
294
295
  network: string;
295
- mppConfig?: {
296
- secretKey: string;
297
- currency: string;
298
- recipient?: string;
299
- rpcUrl?: string;
300
- };
296
+ mppx?: {
297
+ charge: (options: {
298
+ amount: string;
299
+ }) => (input: Request) => Promise<{
300
+ status: 402;
301
+ challenge: Response;
302
+ } | {
303
+ status: 200;
304
+ withReceipt: (response: Response) => Response;
305
+ }>;
306
+ } | null;
301
307
  }
302
308
 
303
309
  type True = true;
@@ -381,13 +387,15 @@ interface MonitorEntry {
381
387
  warn?: number;
382
388
  critical?: number;
383
389
  }
384
- interface ServiceRouter {
385
- route(key: string): RouteBuilder;
390
+ interface ServiceRouter<TPriceKeys extends string = never> {
391
+ route<K extends string>(key: K): [K] extends [TPriceKeys] ? RouteBuilder<undefined, undefined, true, false, false> : RouteBuilder<undefined, undefined, false, false, false>;
386
392
  wellKnown(options?: WellKnownOptions): (request: NextRequest) => Promise<NextResponse>;
387
393
  openapi(options: OpenAPIOptions): (request: NextRequest) => Promise<NextResponse>;
388
394
  monitors(): MonitorEntry[];
389
395
  registry: RouteRegistry;
390
396
  }
391
- declare function createRouter(config: RouterConfig): ServiceRouter;
397
+ declare function createRouter<const P extends Record<string, string> = Record<string, never>>(config: RouterConfig & {
398
+ prices?: P;
399
+ }): ServiceRouter<Extract<keyof P, string>>;
392
400
 
393
401
  export { type AlertEvent, type AlertFn, type AlertLevel, type AuthEvent, type AuthMode, type ErrorEvent, type HandlerContext, HttpError, MemoryNonceStore, type MonitorEntry, type NonceStore, type OveragePolicy, type PaidOptions, type PaymentEvent, type PluginContext, type PricingConfig, type ProtocolType, type ProviderConfig, type ProviderQuotaEvent, type QuotaInfo, type QuotaLevel, type RedisNonceStoreOptions, type RequestMeta, type ResponseMeta, RouteBuilder, type RouteEntry, RouteRegistry, type RouterConfig, type RouterPlugin, SIWX_CHALLENGE_EXPIRY_MS, type ServiceRouter, type SettlementEvent, type TierConfig, type X402Server, consolePlugin, createRedisNonceStore, createRouter };
package/dist/index.js CHANGED
@@ -329,6 +329,9 @@ function resolveMaxPrice(pricing) {
329
329
  return max;
330
330
  }
331
331
 
332
+ // src/orchestrate.ts
333
+ import { Credential } from "mppx";
334
+
332
335
  // src/protocols/x402.ts
333
336
  async function buildX402Challenge(server, routeEntry, request, price, payeeAddress, network, extensions) {
334
337
  const { encodePaymentRequiredHeader } = await import("@x402/core/http");
@@ -394,107 +397,6 @@ async function settleX402Payment(server, payload, requirements) {
394
397
  return { encoded, result };
395
398
  }
396
399
 
397
- // src/protocols/mpp.ts
398
- import { Challenge, Credential, Receipt } from "mppx";
399
- import { tempo } from "mppx/server";
400
- import { Methods } from "mppx/tempo";
401
- import { createClient, http } from "viem";
402
- import { tempo as tempoChain } from "viem/chains";
403
- function buildGetClient(rpcUrl) {
404
- const url = rpcUrl ?? process.env.TEMPO_RPC_URL;
405
- if (!url) return {};
406
- return {
407
- getClient: () => createClient({ chain: tempoChain, transport: http(url) })
408
- };
409
- }
410
- function toStandardRequest(request) {
411
- if (request.constructor.name === "Request") {
412
- return request;
413
- }
414
- return new Request(request.url, {
415
- method: request.method,
416
- headers: request.headers
417
- });
418
- }
419
- var DEFAULT_DECIMALS = 6;
420
- async function buildMPPChallenge(routeEntry, request, mppConfig, price) {
421
- const standardRequest = toStandardRequest(request);
422
- const currency = mppConfig.currency;
423
- const recipient = mppConfig.recipient ?? "";
424
- const challenge = Challenge.fromMethod(Methods.charge, {
425
- secretKey: mppConfig.secretKey,
426
- realm: new URL(standardRequest.url).origin,
427
- request: {
428
- amount: price,
429
- currency,
430
- recipient,
431
- decimals: DEFAULT_DECIMALS
432
- }
433
- });
434
- return Challenge.serialize(challenge);
435
- }
436
- async function verifyMPPCredential(request, _routeEntry, mppConfig, price) {
437
- const standardRequest = toStandardRequest(request);
438
- const currency = mppConfig.currency;
439
- const recipient = mppConfig.recipient ?? "";
440
- try {
441
- const authHeader = standardRequest.headers.get("Authorization");
442
- if (!authHeader) {
443
- console.error("[MPP] No Authorization header found");
444
- return null;
445
- }
446
- const credential = Credential.fromRequest(standardRequest);
447
- if (!credential?.challenge) {
448
- console.error("[MPP] Invalid credential structure");
449
- return null;
450
- }
451
- const isValid = Challenge.verify(credential.challenge, { secretKey: mppConfig.secretKey });
452
- if (!isValid) {
453
- console.error("[MPP] Challenge HMAC verification failed");
454
- return { valid: false, payer: null };
455
- }
456
- const methodIntent = tempo.charge({
457
- ...buildGetClient(mppConfig.rpcUrl)
458
- });
459
- const paymentRequest = {
460
- amount: price,
461
- currency,
462
- recipient,
463
- decimals: DEFAULT_DECIMALS
464
- };
465
- const receipt = await methodIntent.verify({
466
- credential,
467
- request: paymentRequest
468
- });
469
- if (!receipt || receipt.status !== "success") {
470
- console.error("[MPP] Tempo verification failed:", receipt);
471
- return { valid: false, payer: null };
472
- }
473
- const payer = receipt.reference ?? "";
474
- return {
475
- valid: true,
476
- payer,
477
- txHash: receipt.reference
478
- };
479
- } catch (error) {
480
- console.error("[MPP] Credential verification error:", {
481
- message: error instanceof Error ? error.message : String(error),
482
- stack: error instanceof Error ? error.stack : void 0,
483
- errorType: error?.constructor?.name
484
- });
485
- return null;
486
- }
487
- }
488
- function buildMPPReceipt(reference) {
489
- const receipt = Receipt.from({
490
- method: "tempo",
491
- status: "success",
492
- reference,
493
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
494
- });
495
- return Receipt.serialize(receipt);
496
- }
497
-
498
400
  // src/auth/siwx.ts
499
401
  var SIWX_ERROR_MESSAGES = {
500
402
  siwx_missing_header: "Missing SIGN-IN-WITH-X header",
@@ -655,7 +557,8 @@ function createRequestHandler(routeEntry, handler, deps) {
655
557
  }
656
558
  const protocol = detectProtocol(request);
657
559
  let earlyBodyData;
658
- const needsEarlyParse = !protocol && routeEntry.bodySchema && (typeof routeEntry.pricing === "function" || routeEntry.validateFn);
560
+ const pricingNeedsBody = routeEntry.pricing != null && typeof routeEntry.pricing !== "string";
561
+ const needsEarlyParse = !protocol && routeEntry.bodySchema && (pricingNeedsBody || routeEntry.validateFn);
659
562
  if (needsEarlyParse) {
660
563
  const requestForPricing = request.clone();
661
564
  const earlyBodyResult = await parseBody(requestForPricing, routeEntry);
@@ -765,6 +668,21 @@ function createRequestHandler(routeEntry, handler, deps) {
765
668
  return handleAuth(wallet, void 0);
766
669
  }
767
670
  if (!protocol || protocol === "siwx") {
671
+ if (routeEntry.pricing) {
672
+ const initErrors = routeEntry.protocols.map((p) => {
673
+ if (p === "x402" && deps.x402InitError) return `x402: ${deps.x402InitError}`;
674
+ if (p === "mpp" && deps.mppInitError) return `mpp: ${deps.mppInitError}`;
675
+ return null;
676
+ }).filter(Boolean);
677
+ if (initErrors.length > 0) {
678
+ return fail(
679
+ 500,
680
+ `Payment protocol initialization failed. ${initErrors.join("; ")}`,
681
+ meta,
682
+ pluginCtx
683
+ );
684
+ }
685
+ }
768
686
  return await build402(request, routeEntry, deps, meta, pluginCtx, earlyBodyData);
769
687
  }
770
688
  const body = await parseBody(request, routeEntry);
@@ -792,9 +710,22 @@ function createRequestHandler(routeEntry, handler, deps) {
792
710
  pluginCtx
793
711
  );
794
712
  }
713
+ if (!routeEntry.protocols.includes(protocol)) {
714
+ const accepted = routeEntry.protocols.join(", ") || "none";
715
+ console.warn(
716
+ `[router] ${routeEntry.key}: received ${protocol} payment but route accepts [${accepted}]`
717
+ );
718
+ return fail(
719
+ 400,
720
+ `This route does not accept ${protocol} payments. Accepted protocols: ${accepted}`,
721
+ meta,
722
+ pluginCtx
723
+ );
724
+ }
795
725
  if (protocol === "x402") {
796
726
  if (!deps.x402Server) {
797
727
  const reason = deps.x402InitError ? `x402 facilitator initialization failed: ${deps.x402InitError}` : "x402 server not initialized \u2014 ensure @x402/core, @x402/evm, and @coinbase/x402 are installed";
728
+ console.error(`[router] ${routeEntry.key}: ${reason}`);
798
729
  return fail(500, reason, meta, pluginCtx);
799
730
  }
800
731
  const verify = await verifyX402Payment(
@@ -867,10 +798,37 @@ function createRequestHandler(routeEntry, handler, deps) {
867
798
  return response;
868
799
  }
869
800
  if (protocol === "mpp") {
870
- if (!deps.mppConfig) return await build402(request, routeEntry, deps, meta, pluginCtx);
871
- const verify = await verifyMPPCredential(request, routeEntry, deps.mppConfig, price);
872
- if (!verify?.valid) return await build402(request, routeEntry, deps, meta, pluginCtx);
873
- const wallet = verify.payer.toLowerCase();
801
+ if (!deps.mppx) {
802
+ const reason = deps.mppInitError ? `MPP initialization failed: ${deps.mppInitError}` : "MPP not initialized \u2014 ensure mppx is installed and mpp config (secretKey, currency, recipient) is correct";
803
+ console.error(`[router] ${routeEntry.key}: ${reason}`);
804
+ return fail(500, reason, meta, pluginCtx);
805
+ }
806
+ let mppResult;
807
+ try {
808
+ mppResult = await deps.mppx.charge({ amount: price })(request);
809
+ } catch (err) {
810
+ const message = err instanceof Error ? err.message : String(err);
811
+ console.error(`[router] ${routeEntry.key}: MPP charge failed: ${message}`);
812
+ firePluginHook(deps.plugin, "onAlert", pluginCtx, {
813
+ level: "critical",
814
+ message: `MPP charge failed: ${message}`,
815
+ route: routeEntry.key
816
+ });
817
+ return fail(500, `MPP payment processing failed: ${message}`, meta, pluginCtx);
818
+ }
819
+ if (mppResult.status === 402) {
820
+ console.warn(
821
+ `[router] ${routeEntry.key}: MPP credential present but charge() returned 402 \u2014 credential may be invalid, or check TEMPO_RPC_URL configuration`
822
+ );
823
+ firePluginHook(deps.plugin, "onAlert", pluginCtx, {
824
+ level: "warn",
825
+ message: "MPP payment rejected despite credential present \u2014 possible config issue (TEMPO_RPC_URL)",
826
+ route: routeEntry.key
827
+ });
828
+ return await build402(request, routeEntry, deps, meta, pluginCtx);
829
+ }
830
+ const credential = Credential.fromRequest(request);
831
+ const wallet = (credential?.source ?? "").toLowerCase();
874
832
  pluginCtx.setVerifiedWallet(wallet);
875
833
  firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
876
834
  protocol: "mpp",
@@ -887,10 +845,9 @@ function createRequestHandler(routeEntry, handler, deps) {
887
845
  body.data
888
846
  );
889
847
  if (response.status < 400) {
890
- try {
891
- response.headers.set("Payment-Receipt", await buildMPPReceipt(crypto.randomUUID()));
892
- } catch {
893
- }
848
+ const receiptResponse = mppResult.withReceipt(response);
849
+ finalize(receiptResponse, rawResult, meta, pluginCtx);
850
+ return receiptResponse;
894
851
  }
895
852
  finalize(response, rawResult, meta, pluginCtx);
896
853
  return response;
@@ -981,7 +938,7 @@ async function build402(request, routeEntry, deps, meta, pluginCtx, bodyData) {
981
938
  }
982
939
  });
983
940
  let challengePrice;
984
- if (bodyData !== void 0 && typeof routeEntry.pricing === "function") {
941
+ if (bodyData !== void 0 && typeof routeEntry.pricing !== "string" && routeEntry.pricing != null) {
985
942
  const result = await resolveDynamicPrice(bodyData, routeEntry, deps, pluginCtx, meta);
986
943
  if ("error" in result) return result.error;
987
944
  challengePrice = result.price;
@@ -1032,12 +989,13 @@ async function build402(request, routeEntry, deps, meta, pluginCtx, bodyData) {
1032
989
  });
1033
990
  }
1034
991
  }
1035
- if (routeEntry.protocols.includes("mpp") && deps.mppConfig) {
992
+ if (routeEntry.protocols.includes("mpp") && deps.mppx) {
1036
993
  try {
1037
- response.headers.set(
1038
- "WWW-Authenticate",
1039
- await buildMPPChallenge(routeEntry, request, deps.mppConfig, challengePrice)
1040
- );
994
+ const result = await deps.mppx.charge({ amount: challengePrice })(request);
995
+ if (result.status === 402) {
996
+ const wwwAuth = result.challenge.headers.get("WWW-Authenticate");
997
+ if (wwwAuth) response.headers.set("WWW-Authenticate", wwwAuth);
998
+ }
1041
999
  } catch (err) {
1042
1000
  firePluginHook(deps.plugin, "onAlert", pluginCtx, {
1043
1001
  level: "critical",
@@ -1454,21 +1412,30 @@ function createRouter(config) {
1454
1412
  const nonceStore = config.siwx?.nonceStore ?? new MemoryNonceStore();
1455
1413
  const network = config.network ?? "eip155:8453";
1456
1414
  const baseUrl = typeof globalThis.process !== "undefined" ? process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000" : "http://localhost:3000";
1457
- if (config.protocols) {
1458
- if (config.protocols.length === 0) {
1459
- throw new Error(
1460
- "RouterConfig.protocols cannot be empty. Omit the field to use default ['x402'] or specify protocols explicitly."
1461
- );
1462
- }
1463
- if (config.protocols.includes("mpp") && !config.mpp) {
1464
- throw new Error(
1465
- 'RouterConfig.protocols includes "mpp" but RouterConfig.mpp is not configured. Add mpp: { secretKey, currency, recipient } to your router config.'
1466
- );
1415
+ if (config.protocols && config.protocols.length === 0) {
1416
+ throw new Error(
1417
+ "RouterConfig.protocols cannot be empty. Omit the field to use default ['x402'] or specify protocols explicitly."
1418
+ );
1419
+ }
1420
+ let x402ConfigError;
1421
+ let mppConfigError;
1422
+ if ((!config.protocols || config.protocols.includes("x402")) && !config.payeeAddress) {
1423
+ x402ConfigError = "x402 requires payeeAddress in router config.";
1424
+ }
1425
+ if (config.protocols?.includes("mpp")) {
1426
+ if (!config.mpp) {
1427
+ mppConfigError = 'protocols includes "mpp" but mpp config is missing. Add mpp: { secretKey, currency, recipient } to your router config.';
1428
+ } else if (!config.mpp.recipient && !config.payeeAddress) {
1429
+ mppConfigError = "MPP requires a recipient address. Set mpp.recipient or payeeAddress in your router config.";
1430
+ } else if (!(config.mpp.rpcUrl ?? process.env.TEMPO_RPC_URL)) {
1431
+ mppConfigError = "MPP requires an authenticated Tempo RPC URL. Set TEMPO_RPC_URL env var or pass rpcUrl in the mpp config object.";
1467
1432
  }
1468
- if (config.protocols.includes("x402") && !config.payeeAddress) {
1469
- throw new Error(
1470
- 'RouterConfig.protocols includes "x402" but RouterConfig.payeeAddress is not configured.'
1471
- );
1433
+ }
1434
+ const allConfigErrors = [x402ConfigError, mppConfigError].filter(Boolean);
1435
+ if (allConfigErrors.length > 0) {
1436
+ for (const err of allConfigErrors) console.error(`[router] ${err}`);
1437
+ if (process.env.NODE_ENV === "production") {
1438
+ throw new Error(allConfigErrors.join("\n"));
1472
1439
  }
1473
1440
  }
1474
1441
  if (config.plugin?.init) {
@@ -1488,17 +1455,47 @@ function createRouter(config) {
1488
1455
  nonceStore,
1489
1456
  payeeAddress: config.payeeAddress,
1490
1457
  network,
1491
- mppConfig: config.mpp
1458
+ mppx: null
1492
1459
  };
1493
1460
  deps.initPromise = (async () => {
1494
- try {
1495
- const { createX402Server: createX402Server2 } = await Promise.resolve().then(() => (init_server(), server_exports));
1496
- const result = await createX402Server2(config);
1497
- deps.x402Server = result.server;
1498
- await result.initPromise;
1499
- } catch (err) {
1500
- deps.x402Server = null;
1501
- deps.x402InitError = err instanceof Error ? err.message : String(err);
1461
+ if (x402ConfigError) {
1462
+ deps.x402InitError = x402ConfigError;
1463
+ } else {
1464
+ try {
1465
+ const { createX402Server: createX402Server2 } = await Promise.resolve().then(() => (init_server(), server_exports));
1466
+ const result = await createX402Server2(config);
1467
+ deps.x402Server = result.server;
1468
+ await result.initPromise;
1469
+ } catch (err) {
1470
+ deps.x402Server = null;
1471
+ deps.x402InitError = err instanceof Error ? err.message : String(err);
1472
+ }
1473
+ }
1474
+ if (mppConfigError) {
1475
+ deps.mppInitError = mppConfigError;
1476
+ } else if (config.mpp) {
1477
+ try {
1478
+ const { Mppx, tempo } = await import("mppx/server");
1479
+ const rpcUrl = config.mpp.rpcUrl ?? process.env.TEMPO_RPC_URL;
1480
+ deps.mppx = Mppx.create({
1481
+ methods: [
1482
+ tempo.charge({
1483
+ currency: config.mpp.currency,
1484
+ recipient: config.mpp.recipient ?? config.payeeAddress,
1485
+ getClient: async () => {
1486
+ const { createClient, http } = await import("viem");
1487
+ const { tempo: tempoChain } = await import("viem/chains");
1488
+ return createClient({ chain: tempoChain, transport: http(rpcUrl) });
1489
+ }
1490
+ })
1491
+ ],
1492
+ secretKey: config.mpp.secretKey
1493
+ });
1494
+ } catch (err) {
1495
+ deps.mppx = null;
1496
+ deps.mppInitError = err instanceof Error ? err.message : String(err);
1497
+ console.error(`[router] MPP initialization failed: ${deps.mppInitError}`);
1498
+ }
1502
1499
  }
1503
1500
  })();
1504
1501
  const pricesKeys = config.prices ? Object.keys(config.prices) : void 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentcash/router",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
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": {
@@ -28,7 +28,7 @@
28
28
  "@x402/core": "^2.3.0",
29
29
  "@x402/evm": "^2.3.0",
30
30
  "@x402/extensions": "^2.3.0",
31
- "mppx": "^0.2.2",
31
+ "mppx": "^0.2.4",
32
32
  "next": ">=15.0.0",
33
33
  "zod": "^4.0.0",
34
34
  "zod-openapi": "^5.0.0"
@@ -47,8 +47,8 @@
47
47
  "@x402/evm": "^2.3.0",
48
48
  "@x402/extensions": "^2.3.0",
49
49
  "eslint": "^10.0.0",
50
+ "mppx": "^0.2.5",
50
51
  "next": "^15.0.0",
51
- "mppx": "^0.2.2",
52
52
  "prettier": "^3.8.1",
53
53
  "react": "^19.0.0",
54
54
  "tsup": "^8.0.0",