Skip to content

feat: add multi-challenge mppx.compose() combinator#166

Merged
jxom merged 8 commits intomainfrom
feat/multi-challenge-offer
Mar 10, 2026
Merged

feat: add multi-challenge mppx.compose() combinator#166
jxom merged 8 commits intomainfrom
feat/multi-challenge-offer

Conversation

@brendanjryan
Copy link
Collaborator

@brendanjryan brendanjryan commented Mar 9, 2026

Summary

Adds mppx.compose() — an instance method that lets a single route present multiple payment methods (or the same method with different options) to the client via multiple WWW-Authenticate headers (RFC 9110 §11.6.1). Also adds Mppx.compose() as a static function, Challenge.fromResponseList() for parsing merged responses, and updates the client to automatically select from multiple challenges using the preference order declared in the methods array.

Usage

Server: nested accessors

const mppx = Mppx.create({
  methods: [
    tempo.charge({ currency: USDC, recipient: "0x..." }),
    stripe.charge({ currency: "usd" }),
  ],
  secretKey,
})

// All three accessor styles are equivalent:
mppx.tempo.charge({ amount: "100" })        // nested (new)
mppx["tempo/charge"]({ amount: "100" })     // slash key
mppx.charge({ amount: "100" })              // shorthand (when intent is unique)

Server: multi-challenge combinator

app.get("/api/resource", async (req) => {
  const result = await mppx.compose(
    [mppx.tempo.charge, { amount: "100" }],   // method ref
    ["stripe/charge", { amount: "100" }],      // or string key
  )(req)
  if (result.status === 402) return result.challenge
  return result.withReceipt(new Response("OK"))
})

Server: Mppx.compose() static with nested accessors

app.get("/api/resource", async (req) => {
  const result = await Mppx.compose(
    mppx.tempo.charge({ amount: "100" }),
    mppx.stripe.charge({ amount: "100" }),
  )(req)
  if (result.status === 402) return result.challenge
  return result.withReceipt(new Response("OK"))
})

Server: same method, different currencies

app.get("/api/resource", async (req) => {
  const result = await mppx.compose(
    [tempoCharge, { amount: "100", currency: USDC }],
    [tempoCharge, { amount: "100", currency: PathUSD }],
  )(req)
  if (result.status === 402) return result.challenge
  return result.withReceipt(new Response("OK"))
})

Middleware: nested accessors (Hono)

import { Mppx, tempo } from "mppx/hono"

const mppx = Mppx.create({ methods: [tempo.charge(...)], secretKey })

app.get("/premium", mppx.tempo.charge({ amount: "1" }), (c) =>
  c.json({ data: "paid content" }),
)

Client: automatic preference-based selection

The client automatically handles multi-challenge 402 responses. When multiple challenges are present, it selects the first match based on the order of the methods array — the client controls priority:

const mppx = Mppx.create({
  methods: [
    tempo({ account }),   // preferred — tried first
    stripe({ ... }),      // fallback
  ],
})

// Automatically picks tempo if the server offers both
const response = await mppx.fetch("/api/resource")

Client: manual parsing

const response = await fetch("/api/resource")
if (response.status === 402) {
  const challenges = Challenge.fromResponseList(response)
  // challenges[0] => tempo/charge (USDC)
  // challenges[1] => tempo/charge (PathUSD)
}

The 402 response contains multiple WWW-Authenticate headers:

HTTP/1.1 402 Payment Required
WWW-Authenticate: Payment method="tempo" intent="charge" request="..." (USDC)
WWW-Authenticate: Payment method="stripe" intent="charge" request="..." (USD)

Test coverage

Feature Tests
Nested accessors (mppx.alpha.charge(...)) 4 tests — 402→200 flow, identity with slash key, Mppx.compose() static
Middleware wrap() nested support 6 tests — wrapped handler production, end-to-end 402→200, key equivalence, passthrough of compose/realm/transport
String keys in compose() 3 tests — accepts string keys, rejects unknown keys, mixes with method refs
deserializeList quote handling 1 test — "Payment " inside quoted param values
Multi-challenge combinator 12 tests — merged headers, dispatch by method, cross-route protection, withReceipt, currency/recipient disambiguation, pre-dispatch narrowing

Adds an instance method that lets a single route present multiple payment
methods to the client via multiple WWW-Authenticate headers (RFC 9110 §11.6.1).

Usage:
  const result = await mppx.challenge(
    [tempoCharge, { amount: '100' }],
    [stripeCharge, { amount: '100' }],
  )(request)

- No credential: calls all handlers, merges WWW-Authenticate headers into one 402
- With credential: dispatches to matching handler by method+intent
- Cross-route protection preserved through offer()
- Also exports static Mppx.offer() for pre-configured handler composition
- 8 new tests covering dispatch, merge, cross-route, withReceipt, error cases
…-based selection, pre-dispatch narrowing

- Rename static offer() to challenge(), OfferEntry to ChallengeEntry, all test/comment references
- Add Challenge.fromResponseList/fromHeadersList/deserializeList for parsing merged WWW-Authenticate
- Client Fetch.ts: use fromResponseList and select in methods declaration order (preference stack)
- Pre-dispatch narrowing: compare currency/recipient from echoed credential against _internal metadata
- Protect _internal.name/intent from being clobbered by user options (spread last)
- Add tests for same name/intent with different currencies and recipients
- Add comment on Wrap type explaining why challenge is passed through unwrapped
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 9, 2026

Open in StackBlitz

npm i https://pkg.pr.new/wevm/mppx@166

commit: b86c8ee

…wing

- Only enter credential dispatch when Authorization header contains a
  Payment scheme. Non-payment auth (Bearer, Basic) now falls through
  to the merged-402 path so all offers are presented.
- Add 'amount' to pre-dispatch narrowing fields so same-method offers
  differing only by amount are correctly disambiguated.
* })
*
* app.get('/api/resource', async (req) => {
* const result = await Mppx.challenge(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it meant to be mppx.challenge?

Suggested change
* const result = await Mppx.challenge(
* const result = await mppx.challenge(

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if mppx.compose might be better naming, in case we want to use mppx.challenge for something else (like arbitrary server-defined digests like we discussed earlier? feel like that might be a good name for it)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah lets' try compose, I think that is more explicit

Comment on lines +45 to +46
* mppx.tempo.charge({ amount: '100' }),
* mppx.stripe.charge({ amount: '100' }),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we make sure the types are hooked up well here for these variants with .test-d.ts type tests?

- Rename challenge → compose per review feedback (reserve 'challenge'
  for future server-defined digest API).
- Fix JSDoc example to use instance method form (mppx.compose not Mppx.challenge).
- Add Mppx.test-d.ts with vitest typecheck tests covering compose() with
  method reference tuples, string key tuples, nested handlers, and slash
  key handlers.
- Update middleware Wrap type exclusion and tests accordingly.
@brendanjryan brendanjryan changed the title feat: add multi-challenge mppx.challenge() combinator feat: add multi-challenge mppx.compose() combinator Mar 10, 2026
@jxom jxom merged commit 79bbfc6 into main Mar 10, 2026
7 checks passed
@jxom jxom deleted the feat/multi-challenge-offer branch March 10, 2026 20:25
brendanjryan added a commit that referenced this pull request Mar 12, 2026
…-info)

Implements mppx discovery per draft-payment-discovery-00 (mpp-specs #166).

Phase 1 — Schema + Validator + CLI:
- Zod schemas for PaymentInfo, ServiceInfo, DiscoveryDocument
- validate() with structural parsing + semantic checks
- 'discover validate <url-or-file>' CLI subcommand
- 26 tests (Discovery + Validate)

Phase 2 — OpenAPI Generator:
- generate(mppx, config) builds OpenAPI 3.1.0 doc from mppx.methods
- Annotates routes with x-service-info and x-payment-info extensions
- 6 tests

Phase 3 — Framework Plugins:
- Hono: discovery(app, mppx, config) mounts GET /openapi.json
- Express: discovery(app, mppx, config) mounts GET /openapi.json
- Next.js: discovery(mppx, config) returns a route handler

Phase 4 — Auto-introspection (Hono):
- discovery(app, mppx, { auto: true }) walks app.routes and matches
  handlers with _internal metadata to build RouteConfig[] automatically

Infrastructure:
- ./discovery export in package.json
- mppx/discovery alias in vitest.config.ts
- src/discovery/index.ts re-exports
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants