From a64c1f2048569377c076c7906fb16e247973e598 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 14 Apr 2026 08:53:50 -0700 Subject: [PATCH 1/5] proposal: challenge generation and credential verification for UCP --- proposals/ucp-api-improvements.md | 116 ++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 proposals/ucp-api-improvements.md diff --git a/proposals/ucp-api-improvements.md b/proposals/ucp-api-improvements.md new file mode 100644 index 00000000..814e8acc --- /dev/null +++ b/proposals/ucp-api-improvements.md @@ -0,0 +1,116 @@ +# Proposal: UCP API Improvements + +Two new methods on `Mppx` to support non-HTTP-402 integrations (UCP, webhooks, queue consumers) where the server generates challenges and verifies credentials outside the request lifecycle. + +## Problem + +When using mppx in a UCP handler, you bypass `Mppx.create()`'s internal pipeline and hit two pain points: + +1. **Challenge generation** — `Challenge.from()` / `Challenge.fromIntent()` bypass the method's schema transforms (e.g. `parseUnits`), so you manually convert amounts to base units. `mppx.charge()` does this for you, but returns a request handler, not a challenge. + +2. **Credential verification** — No standalone verify. You manually deserialize the credential, HMAC-check the challenge, find the method, validate the payload schema, reconstruct request params in the right unit format, then call the undocumented `method.verify()`. That's 5 steps that `createMethodFn` already does internally. + +## Proposed API + +### `mppx.challenge.{method}.{intent}(opts)` — Challenge generation + +Same options type and schema transforms as `mppx.{method}.{intent}()`, returns a `Challenge` object directly instead of a request handler. + +```ts +const mppx = Mppx.create({ + methods: [tempo({ currency: USDC, recipient: '0x...' })], + secretKey: process.env.MPP_SECRET_KEY, +}) + +// Returns a Challenge object (not a request handler) +const challenge = mppx.challenge.tempo.charge({ + amount: '25.92', // human-readable — SDK applies parseUnits + description: 'Order #123', + expires: '2026-04-14T17:00:00Z', +}) +``` + +**Type sketch:** + +```ts +type Mppx = { + // ... existing fields ... + + challenge: { + [name in methods[number]['name']]: { + [mi in Extract as mi['intent']]: ( + options: MethodFn.Options> + ) => Challenge.Challenge + } + } +} +``` + +**Implementation:** Extract lines 262–304 of `createMethodFn` (merge defaults → transform request → `Challenge.fromMethod`) into a standalone function. No new logic. + +### `mppx.verifyCredential(credential)` — Single-call verification + +Deserializes, HMAC-checks, matches the method, validates the payload, and calls `verify()`. Returns a `Receipt`. + +```ts +// From a raw credential string (e.g. UCP instrument value) +const receipt = await mppx.verifyCredential('eyJjaGFsbGVuZ2...') + +// Or from an already-parsed Credential object +const receipt = await mppx.verifyCredential(credential) +``` + +**Type sketch:** + +```ts +type Mppx = { + // ... existing fields ... + + verifyCredential( + credential: string | Credential.Credential + ): Promise +} +``` + +**Implementation:** Extract lines 329–435 of `createMethodFn` (HMAC verify → pinned field check → expiry check → schema validate → `verify()`) into a standalone function. Method resolution uses `credential.challenge.method` + `credential.challenge.intent` to find the registered handler, same dispatch logic as `compose`. + +Key insight: the challenge already contains the request params (HMAC-bound), so the server doesn't re-supply them. + +## UCP Integration Before/After + +**Before:** +```ts +// Challenge generation — manual base-unit conversion +const challenge = Challenge.from({ + realm: 'merchant.example.com', + method: 'tempo', + intent: 'charge', + secretKey: process.env.MPP_SECRET_KEY, + request: { + amount: '25920000', // you convert to base units + currency: '0x20c0...00', + recipient: account.address, + decimals: 6, // you supply this + methodDetails: { chainId: 42431, feePayer: true }, + }, +}) + +// Verification — 5 manual steps +const cred = Credential.deserialize(instrumentCredential) +if (!Challenge.verify(cred.challenge, { secretKey })) throw new Error('bad HMAC') +const method = mppx.methods.find(m => m.name === cred.challenge.method) +method.schema.credential.payload.parse(cred.payload) +const receipt = await method.verify({ credential: cred, request: { ... } }) +``` + +**After:** +```ts +// Challenge generation — same input shape as mppx.tempo.charge() +const challenge = mppx.challenge.tempo.charge({ + amount: '25.92', + description: 'Order #123', +}) + +// Verification — one call +const receipt = await mppx.verifyCredential(instrumentCredential) +``` From e10a517152f3d51ced0e7f3ceed35a1575c3a09d Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 14 Apr 2026 09:00:40 -0700 Subject: [PATCH 2/5] feat: mppx.challenge and mppx.verifyCredential for non-402 integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new methods on the Mppx instance: - mppx.challenge.{method}.{intent}(opts) — generates Challenge objects using the same options, defaults, and schema transforms as the 402 handler. Eliminates manual base-unit conversion for UCP/webhook use. - mppx.verifyCredential(credential) — single-call end-to-end verification: deserialize, HMAC-check, method match, schema validate, expiry check, and verify. Replaces 5 manual steps. Both are extractions of existing createMethodFn internals. Tests cover charge + session intents, multi-method dispatch, schema transforms, HMAC rejection, expiry, invalid payloads, unregistered methods, malformed input, and full round-trip flows. --- proposals/ucp-api-improvements.md | 116 ------- src/server/Mppx.test-d.ts | 33 ++ src/server/Mppx.test.ts | 560 ++++++++++++++++++++++++++++++ src/server/Mppx.ts | 147 +++++++- 4 files changed, 739 insertions(+), 117 deletions(-) delete mode 100644 proposals/ucp-api-improvements.md diff --git a/proposals/ucp-api-improvements.md b/proposals/ucp-api-improvements.md deleted file mode 100644 index 814e8acc..00000000 --- a/proposals/ucp-api-improvements.md +++ /dev/null @@ -1,116 +0,0 @@ -# Proposal: UCP API Improvements - -Two new methods on `Mppx` to support non-HTTP-402 integrations (UCP, webhooks, queue consumers) where the server generates challenges and verifies credentials outside the request lifecycle. - -## Problem - -When using mppx in a UCP handler, you bypass `Mppx.create()`'s internal pipeline and hit two pain points: - -1. **Challenge generation** — `Challenge.from()` / `Challenge.fromIntent()` bypass the method's schema transforms (e.g. `parseUnits`), so you manually convert amounts to base units. `mppx.charge()` does this for you, but returns a request handler, not a challenge. - -2. **Credential verification** — No standalone verify. You manually deserialize the credential, HMAC-check the challenge, find the method, validate the payload schema, reconstruct request params in the right unit format, then call the undocumented `method.verify()`. That's 5 steps that `createMethodFn` already does internally. - -## Proposed API - -### `mppx.challenge.{method}.{intent}(opts)` — Challenge generation - -Same options type and schema transforms as `mppx.{method}.{intent}()`, returns a `Challenge` object directly instead of a request handler. - -```ts -const mppx = Mppx.create({ - methods: [tempo({ currency: USDC, recipient: '0x...' })], - secretKey: process.env.MPP_SECRET_KEY, -}) - -// Returns a Challenge object (not a request handler) -const challenge = mppx.challenge.tempo.charge({ - amount: '25.92', // human-readable — SDK applies parseUnits - description: 'Order #123', - expires: '2026-04-14T17:00:00Z', -}) -``` - -**Type sketch:** - -```ts -type Mppx = { - // ... existing fields ... - - challenge: { - [name in methods[number]['name']]: { - [mi in Extract as mi['intent']]: ( - options: MethodFn.Options> - ) => Challenge.Challenge - } - } -} -``` - -**Implementation:** Extract lines 262–304 of `createMethodFn` (merge defaults → transform request → `Challenge.fromMethod`) into a standalone function. No new logic. - -### `mppx.verifyCredential(credential)` — Single-call verification - -Deserializes, HMAC-checks, matches the method, validates the payload, and calls `verify()`. Returns a `Receipt`. - -```ts -// From a raw credential string (e.g. UCP instrument value) -const receipt = await mppx.verifyCredential('eyJjaGFsbGVuZ2...') - -// Or from an already-parsed Credential object -const receipt = await mppx.verifyCredential(credential) -``` - -**Type sketch:** - -```ts -type Mppx = { - // ... existing fields ... - - verifyCredential( - credential: string | Credential.Credential - ): Promise -} -``` - -**Implementation:** Extract lines 329–435 of `createMethodFn` (HMAC verify → pinned field check → expiry check → schema validate → `verify()`) into a standalone function. Method resolution uses `credential.challenge.method` + `credential.challenge.intent` to find the registered handler, same dispatch logic as `compose`. - -Key insight: the challenge already contains the request params (HMAC-bound), so the server doesn't re-supply them. - -## UCP Integration Before/After - -**Before:** -```ts -// Challenge generation — manual base-unit conversion -const challenge = Challenge.from({ - realm: 'merchant.example.com', - method: 'tempo', - intent: 'charge', - secretKey: process.env.MPP_SECRET_KEY, - request: { - amount: '25920000', // you convert to base units - currency: '0x20c0...00', - recipient: account.address, - decimals: 6, // you supply this - methodDetails: { chainId: 42431, feePayer: true }, - }, -}) - -// Verification — 5 manual steps -const cred = Credential.deserialize(instrumentCredential) -if (!Challenge.verify(cred.challenge, { secretKey })) throw new Error('bad HMAC') -const method = mppx.methods.find(m => m.name === cred.challenge.method) -method.schema.credential.payload.parse(cred.payload) -const receipt = await method.verify({ credential: cred, request: { ... } }) -``` - -**After:** -```ts -// Challenge generation — same input shape as mppx.tempo.charge() -const challenge = mppx.challenge.tempo.charge({ - amount: '25.92', - description: 'Order #123', -}) - -// Verification — one call -const receipt = await mppx.verifyCredential(instrumentCredential) -``` diff --git a/src/server/Mppx.test-d.ts b/src/server/Mppx.test-d.ts index 8f9914a0..334ae070 100644 --- a/src/server/Mppx.test-d.ts +++ b/src/server/Mppx.test-d.ts @@ -133,4 +133,37 @@ describe('Mppx type tests', () => { test('static Mppx.compose accepts configured handlers', () => { expectTypeOf(Mppx.compose).toBeFunction() }) + + test('challenge namespace has nested accessors matching methods', () => { + const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey }) + + expectTypeOf(mppx.challenge).toBeObject() + expectTypeOf(mppx.challenge.alpha).toBeObject() + expectTypeOf(mppx.challenge.alpha.charge).toBeFunction() + expectTypeOf(mppx.challenge.beta).toBeObject() + expectTypeOf(mppx.challenge.beta.charge).toBeFunction() + }) + + test('challenge functions return Challenge type', () => { + const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey }) + + const challenge = mppx.challenge.alpha.charge({ + amount: '100', + currency: '0x01', + decimals: 6, + recipient: '0x02', + }) + + expectTypeOf(challenge).toHaveProperty('id') + expectTypeOf(challenge).toHaveProperty('realm') + expectTypeOf(challenge).toHaveProperty('method') + expectTypeOf(challenge).toHaveProperty('intent') + expectTypeOf(challenge).toHaveProperty('request') + }) + + test('verifyCredential exists and returns Promise', () => { + const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey }) + + expectTypeOf(mppx.verifyCredential).toBeFunction() + }) }) diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index a74d4568..72773dd8 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -2741,3 +2741,563 @@ describe('realm auto-detection', () => { expect(body.detail).toContain('realm') }) }) + +// ── mppx.challenge ────────────────────────────────────────────────────── + +describe('challenge', () => { + const mockCharge = Method.from({ + name: 'alpha', + intent: 'charge', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.object({ + amount: z.string(), + currency: z.string(), + decimals: z.number(), + recipient: z.string(), + }), + }, + }) + + const mockSession = Method.from({ + name: 'alpha', + intent: 'session', + schema: { + credential: { + payload: z.discriminatedUnion('action', [ + z.object({ action: z.literal('open'), token: z.string() }), + z.object({ action: z.literal('voucher'), amount: z.string() }), + ]), + }, + request: z.object({ + amount: z.string(), + currency: z.string(), + recipient: z.string(), + unitType: z.string(), + }), + }, + }) + + const betaCharge = Method.from({ + name: 'beta', + intent: 'charge', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.object({ + amount: z.string(), + currency: z.string(), + decimals: z.number(), + recipient: z.string(), + }), + }, + }) + + function mockReceipt(name: string) { + return { + method: name, + reference: `tx-${name}`, + status: 'success' as const, + timestamp: new Date().toISOString(), + } + } + + const alphaChargeServer = Method.toServer(mockCharge, { + async verify() { + return mockReceipt('alpha') + }, + }) + + const alphaSessionServer = Method.toServer(mockSession, { + async verify() { + return mockReceipt('alpha-session') + }, + }) + + const betaChargeServer = Method.toServer(betaCharge, { + async verify() { + return mockReceipt('beta') + }, + }) + + const challengeOpts = { + amount: '1000', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + expires: new Date(Date.now() + 60_000).toISOString(), + recipient: '0x0000000000000000000000000000000000000002', + } + + test('mppx.challenge.alpha.charge returns a valid Challenge object', () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer, alphaSessionServer, betaChargeServer], + realm, + secretKey, + }) + + const challenge = mppx.challenge.alpha.charge(challengeOpts) + + expect(challenge.method).toBe('alpha') + expect(challenge.intent).toBe('charge') + expect(challenge.realm).toBe(realm) + expect(challenge.request.amount).toBe('1000') + expect(challenge.request.currency).toBe('0x0000000000000000000000000000000000000001') + expect(challenge.request.recipient).toBe('0x0000000000000000000000000000000000000002') + expect(challenge.id).toBeDefined() + }) + + test('mppx.challenge.alpha.session returns a valid Challenge object', () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer, alphaSessionServer, betaChargeServer], + realm, + secretKey, + }) + + const challenge = mppx.challenge.alpha.session({ + amount: '500', + currency: '0x0000000000000000000000000000000000000001', + recipient: '0x0000000000000000000000000000000000000002', + unitType: 'token', + }) + + expect(challenge.method).toBe('alpha') + expect(challenge.intent).toBe('session') + expect(challenge.realm).toBe(realm) + expect(challenge.request.unitType).toBe('token') + }) + + test('mppx.challenge.beta.charge returns challenge for a different method', () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer, betaChargeServer], + realm, + secretKey, + }) + + const challenge = mppx.challenge.beta.charge(challengeOpts) + + expect(challenge.method).toBe('beta') + expect(challenge.intent).toBe('charge') + }) + + test('challenge ID is HMAC-bound and verifiable', () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer], + realm, + secretKey, + }) + + const challenge = mppx.challenge.alpha.charge(challengeOpts) + expect(Challenge.verify(challenge, { secretKey })).toBe(true) + }) + + test('challenge includes description and meta when provided', () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer], + realm, + secretKey, + }) + + const challenge = mppx.challenge.alpha.charge({ + ...challengeOpts, + description: 'Order #123', + meta: { checkout_id: 'chk_abc' }, + }) + + expect(challenge.description).toBe('Order #123') + expect(challenge.opaque).toEqual({ checkout_id: 'chk_abc' }) + }) + + test('challenge applies schema transforms', () => { + // Method with a z.transform that converts decimals + const transformMethod = Method.from({ + name: 'transform', + intent: 'charge', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.pipe( + z.object({ + amount: z.string(), + currency: z.string(), + decimals: z.number(), + recipient: z.string(), + }), + z.transform(({ amount, currency, decimals, recipient }) => ({ + amount: String(Number(amount) * 10 ** decimals), + currency, + recipient, + })), + ), + }, + }) + + const serverMethod = Method.toServer(transformMethod, { + async verify() { + return mockReceipt('transform') + }, + }) + + const mppx = Mppx.create({ methods: [serverMethod], realm, secretKey }) + + const challenge = mppx.challenge.transform.charge({ + amount: '25.92', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + recipient: '0x0000000000000000000000000000000000000002', + }) + + // Schema transform should apply: 25.92 * 10^6 = 25920000 + expect(challenge.request.amount).toBe('25920000') + }) + + test('challenge produced by mppx.challenge is accepted by the 402 handler', async () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer], + realm, + secretKey, + }) + + // Generate challenge via the new API + const challenge = mppx.challenge.alpha.charge(challengeOpts) + + // Build a credential from it + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + + // Present it to the 402 handler + const result = await mppx.charge(challengeOpts)( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(result.status).toBe(200) + }) +}) + +// ── mppx.verifyCredential ─────────────────────────────────────────────── + +describe('verifyCredential', () => { + const mockCharge = Method.from({ + name: 'alpha', + intent: 'charge', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.object({ + amount: z.string(), + currency: z.string(), + decimals: z.number(), + recipient: z.string(), + }), + }, + }) + + const mockSession = Method.from({ + name: 'alpha', + intent: 'session', + schema: { + credential: { + payload: z.discriminatedUnion('action', [ + z.object({ action: z.literal('open'), token: z.string() }), + z.object({ action: z.literal('voucher'), amount: z.string() }), + ]), + }, + request: z.object({ + amount: z.string(), + currency: z.string(), + recipient: z.string(), + unitType: z.string(), + }), + }, + }) + + const betaCharge = Method.from({ + name: 'beta', + intent: 'charge', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.object({ + amount: z.string(), + currency: z.string(), + decimals: z.number(), + recipient: z.string(), + }), + }, + }) + + function mockReceipt(name: string) { + return { + method: name, + reference: `tx-${name}`, + status: 'success' as const, + timestamp: new Date().toISOString(), + } + } + + let verifyArgs: Record | undefined + + const alphaChargeServer = Method.toServer(mockCharge, { + async verify({ credential, request }) { + verifyArgs = { credential, request } + return mockReceipt('alpha') + }, + }) + + const alphaSessionServer = Method.toServer(mockSession, { + async verify({ credential, request }) { + verifyArgs = { credential, request } + return mockReceipt('alpha-session') + }, + }) + + const betaChargeServer = Method.toServer(betaCharge, { + async verify() { + return mockReceipt('beta') + }, + }) + + const challengeOpts = { + amount: '1000', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + expires: new Date(Date.now() + 60_000).toISOString(), + recipient: '0x0000000000000000000000000000000000000002', + } + + test('verifies a serialized credential string (charge)', async () => { + verifyArgs = undefined + const mppx = Mppx.create({ + methods: [alphaChargeServer, alphaSessionServer, betaChargeServer], + realm, + secretKey, + }) + + const challenge = mppx.challenge.alpha.charge(challengeOpts) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + const serialized = Credential.serialize(credential) + + const receipt = await mppx.verifyCredential(serialized) + + expect(receipt.status).toBe('success') + expect(receipt.method).toBe('alpha') + expect(verifyArgs).toBeDefined() + }) + + test('verifies a parsed Credential object (charge)', async () => { + verifyArgs = undefined + const mppx = Mppx.create({ + methods: [alphaChargeServer], + realm, + secretKey, + }) + + const challenge = mppx.challenge.alpha.charge(challengeOpts) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + + const receipt = await mppx.verifyCredential(credential) + + expect(receipt.status).toBe('success') + expect(receipt.method).toBe('alpha') + }) + + test('verifies a credential for session intent', async () => { + verifyArgs = undefined + const mppx = Mppx.create({ + methods: [alphaChargeServer, alphaSessionServer], + realm, + secretKey, + }) + + const challenge = mppx.challenge.alpha.session({ + amount: '500', + currency: '0x0000000000000000000000000000000000000001', + recipient: '0x0000000000000000000000000000000000000002', + unitType: 'token', + }) + const credential = Credential.from({ + challenge, + payload: { action: 'open', token: 'valid' }, + }) + + const receipt = await mppx.verifyCredential(credential) + + expect(receipt.status).toBe('success') + expect(receipt.method).toBe('alpha-session') + }) + + test('dispatches to correct method when multiple methods are registered', async () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer, betaChargeServer], + realm, + secretKey, + }) + + const challenge = mppx.challenge.beta.charge(challengeOpts) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + + const receipt = await mppx.verifyCredential(credential) + + expect(receipt.method).toBe('beta') + }) + + test('rejects credential with wrong HMAC (not issued by this server)', async () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer], + realm, + secretKey, + }) + + const wrongChallenge = Challenge.from({ + id: 'tampered-id', + intent: 'charge', + method: 'alpha', + realm, + request: { + amount: '1000', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + recipient: '0x0000000000000000000000000000000000000002', + }, + }) + const credential = Credential.from({ + challenge: wrongChallenge, + payload: { token: 'valid' }, + }) + + await expect(mppx.verifyCredential(credential)).rejects.toThrow( + 'challenge was not issued by this server', + ) + }) + + test('rejects credential with expired challenge', async () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer], + realm, + secretKey, + }) + + const challenge = mppx.challenge.alpha.charge({ + ...challengeOpts, + expires: new Date(Date.now() - 1000).toISOString(), // already expired + }) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + + await expect(mppx.verifyCredential(credential)).rejects.toThrow() + }) + + test('rejects credential with invalid payload schema', async () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer], + realm, + secretKey, + }) + + const challenge = mppx.challenge.alpha.charge(challengeOpts) + const credential = Credential.from({ + challenge, + payload: { wrong_field: 123 }, // doesn't match z.object({ token: z.string() }) + }) + + await expect(mppx.verifyCredential(credential)).rejects.toThrow() + }) + + test('rejects credential for unregistered method/intent', async () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer], + realm, + secretKey, + }) + + // Forge a challenge for an unregistered method using the same secret + const challenge = Challenge.from({ + secretKey, + intent: 'charge', + method: 'unknown', + realm, + expires: new Date(Date.now() + 60_000).toISOString(), + request: { + amount: '1000', + currency: '0x0000000000000000000000000000000000000001', + }, + }) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + + await expect(mppx.verifyCredential(credential)).rejects.toThrow( + 'no registered method for unknown/charge', + ) + }) + + test('rejects malformed credential string', async () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer], + realm, + secretKey, + }) + + await expect(mppx.verifyCredential('not-valid-base64')).rejects.toThrow() + }) + + test('challenge + verifyCredential round-trip with schema transforms', async () => { + const transformMethod = Method.from({ + name: 'transform', + intent: 'charge', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.pipe( + z.object({ + amount: z.string(), + currency: z.string(), + decimals: z.number(), + recipient: z.string(), + }), + z.transform(({ amount, currency, decimals, recipient }) => ({ + amount: String(Number(amount) * 10 ** decimals), + currency, + recipient, + })), + ), + }, + }) + + const serverMethod = Method.toServer(transformMethod, { + async verify() { + return mockReceipt('transform') + }, + }) + + const mppx = Mppx.create({ methods: [serverMethod], realm, secretKey }) + + // Generate challenge with human-readable amount + const challenge = mppx.challenge.transform.charge({ + amount: '25.92', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + recipient: '0x0000000000000000000000000000000000000002', + }) + + // Verify the transform was applied + expect(challenge.request.amount).toBe('25920000') + + // Build credential and verify end-to-end + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + const receipt = await mppx.verifyCredential(credential) + + expect(receipt.status).toBe('success') + expect(receipt.method).toBe('transform') + }) + + test('challenge + verifyCredential round-trip with serialized string', async () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer, alphaSessionServer], + realm, + secretKey, + }) + + // Generate, serialize, verify — the full UCP flow + const challenge = mppx.challenge.alpha.charge(challengeOpts) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + const serialized = Credential.serialize(credential) + + // Simulate receiving the credential string from a UCP instrument + const receipt = await mppx.verifyCredential(serialized) + + expect(receipt.status).toBe('success') + }) +}) diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 8b190c5d..4a9c053e 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -70,7 +70,34 @@ export type Mppx< ): (input: Request) => Promise> } : {}) & - Handlers, transport> + Handlers, transport> & { + /** + * Generate Challenge objects for registered methods without going through + * the HTTP 402 request lifecycle. Uses the same options, defaults, and + * schema transforms as the corresponding intent handler. + * + * @example + * ```ts + * const challenge = mppx.challenge.tempo.charge({ amount: '25.92' }) + * ``` + */ + challenge: ChallengeHandlers> + + /** + * Verify a credential string or object end-to-end: deserialize, + * HMAC-check, match to a registered method, validate payload schema, + * check expiry, and call the method's verify function. + * + * @example + * ```ts + * const receipt = await mppx.verifyCredential('eyJjaGFsbGVuZ2...') + * const receipt = await mppx.verifyCredential(credential) + * ``` + */ + verifyCredential( + credential: string | Credential.Credential, + ): Promise + } /** Extracts the transport override from a method, if any. */ type TransportOverrideOf = mi extends { transport?: infer transport } @@ -136,6 +163,22 @@ type Handlers< } & UniqueIntentHandlers & NestedHandlers +/** Nested challenge generators: `mppx.challenge.tempo.charge(...)`. */ +type ChallengeHandlers = { + [name in methods[number]['name']]: { + [mi in Extract as mi['intent']]: ChallengeFn< + mi, + NonNullable + > + } +} + +/** A function that generates a Challenge object from intent options. */ +type ChallengeFn< + method extends Method.Method, + defaults extends Record, +> = (options: MethodFn.Options) => Challenge.Challenge + /** * Creates a server-side payment handler from methods. * @@ -200,6 +243,59 @@ export function create< ;(handlers[mi.name] as Record)[mi.intent] = fn } + // Build challenge generators: mppx.challenge.tempo.charge(...) + const challengeHandlers: Record> = {} + for (const mi of methods) { + if (!challengeHandlers[mi.name]) challengeHandlers[mi.name] = {} + challengeHandlers[mi.name]![mi.intent] = createChallengeFn({ + defaults: mi.defaults, + method: mi, + realm, + request: mi.request as never, + secretKey, + }) + } + + // verifyCredential: single-call end-to-end verification + async function verifyCredentialFn( + input: string | Credential.Credential, + ): Promise { + const credential = + typeof input === 'string' ? Credential.deserialize(input) : input + + // HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create()) + if (!Challenge.verify(credential.challenge, { secretKey: secretKey! })) + throw new Errors.InvalidChallengeError({ + id: credential.challenge.id, + reason: 'challenge was not issued by this server', + }) + + // Expiry check + Expires.assert(credential.challenge.expires, credential.challenge.id) + + // Find matching method by name + intent + const { method: credMethod, intent: credIntent } = credential.challenge + const mi = (methods as readonly Method.AnyServer[]).find( + (m) => m.name === credMethod && m.intent === credIntent, + ) + if (!mi) + throw new Errors.InvalidChallengeError({ + id: credential.challenge.id, + reason: `no registered method for ${credMethod}/${credIntent}`, + }) + + // Validate payload against method schema + mi.schema.credential.payload.parse(credential.payload) + + // The challenge already contains the request params (HMAC-bound), + // so we use them directly — no need for the caller to re-supply. + const request = credential.challenge.request as z.input< + typeof mi.schema.request + > + + return mi.verify({ credential, request } as never) + } + function composeFn( ...entries: readonly [ Method.AnyServer | AnyMethodFnWithMethod | string, @@ -225,9 +321,11 @@ export function create< return { methods, + challenge: challengeHandlers, compose: composeFn, realm: realm as string | undefined, transport, + verifyCredential: verifyCredentialFn, ...handlers, } as never } @@ -482,6 +580,53 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R } } +/** + * Creates a challenge generator for a single method+intent. + * Applies the same defaults and request transform as createMethodFn, + * but returns a Challenge object directly instead of a request handler. + */ +function createChallengeFn(parameters: { + defaults?: Record + method: Method.Method + realm: string | undefined + request?: Method.RequestFn + secretKey: string +}): (options: Record) => Challenge.Challenge { + const { defaults, method, realm, secretKey } = parameters + + return (options) => { + const { description, meta, ...rest } = options as { + description?: string + expires?: string + meta?: Record + [key: string]: unknown + } + const merged = { ...defaults, ...rest } + const expires = + 'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5) + + // Transform request if method provides a `request` function. + const request = ( + parameters.request + ? (parameters.request as (opts: { request: unknown }) => unknown)({ + request: merged, + }) + : merged + ) as never + + const effectiveRealm = realm ?? defaultRealm + + return Challenge.fromMethod(method, { + description, + expires, + meta, + realm: effectiveRealm, + request, + secretKey, + }) + } +} + function getSafeCredentialReason(error: unknown): string | undefined { if (error instanceof Credential.InvalidCredentialEncodingError) return error.message if (error instanceof Credential.MissingAuthorizationHeaderError) return error.message From 2006d7e75e16d1bc18a653a47ec44b8362a574f4 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 14 Apr 2026 09:17:53 -0700 Subject: [PATCH 3/5] fix: override follow-redirects to >=1.16.0 for audit --- pnpm-lock.yaml | 60 +++++++++++++++++++++++++++++++++++++-------- pnpm-workspace.yaml | 1 + src/server/Mppx.ts | 18 +++++--------- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 775ef7e9..b7b94120 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,7 @@ overrides: yaml@<2.8.3: '>=2.8.3' brace-expansion@<5.0.5: '>=5.0.5' lodash@<=4.17.23: '>=4.18.0' + follow-redirects@<=1.15.11: '>=1.16.0' importers: @@ -322,6 +323,33 @@ importers: specifier: latest version: 8.0.5(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) + examples/subscription: + dependencies: + '@remix-run/node-fetch-server': + specifier: 0.13.0 + version: 0.13.0 + '@types/node': + specifier: 25.5.0 + version: 25.5.0 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260323.1 + version: 7.0.0-dev.20260323.1 + bun: + specifier: 1.3.11 + version: 1.3.11 + mppx: + specifier: workspace:* + version: link:../.. + typescript: + specifier: ~5.9.3 + version: 5.9.3 + viem: + specifier: ^2.47.5 + version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + vite: + specifier: 8.0.5 + version: 8.0.5(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) + src/stripe/server/internal/html: dependencies: '@stripe/stripe-js': @@ -1442,6 +1470,9 @@ packages: '@types/node@25.5.2': resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} @@ -3505,6 +3536,9 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici@7.24.0: resolution: {integrity: sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg==} engines: {node: '>=20.18.1'} @@ -4707,7 +4741,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/chai@5.2.3': dependencies: @@ -4716,7 +4750,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/debug@4.1.12': dependencies: @@ -4726,13 +4760,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/ssh2': 1.15.5 '@types/dockerode@3.3.47': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/ssh2': 1.15.5 '@types/esrecurse@4.3.1': {} @@ -4741,7 +4775,7 @@ snapshots: '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -4772,6 +4806,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} @@ -4786,20 +4824,20 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/ssh2@0.5.52': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -6386,7 +6424,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.5.2 + '@types/node': 25.6.0 long: 5.3.2 proxy-addr@2.0.7: @@ -6856,6 +6894,8 @@ snapshots: undici-types@7.18.2: {} + undici-types@7.19.2: {} + undici@7.24.0: {} unicorn-magic@0.3.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d339a19e..6b3fed8f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -43,5 +43,6 @@ overrides: yaml@<2.8.3: '>=2.8.3' brace-expansion@<5.0.5: '>=5.0.5' lodash@<=4.17.23: '>=4.18.0' + follow-redirects@<=1.15.11: '>=1.16.0' nodeOptions: '--disable-warning=ExperimentalWarning --disable-warning=DeprecationWarning' diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 4a9c053e..2a3e1514 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -94,9 +94,7 @@ export type Mppx< * const receipt = await mppx.verifyCredential(credential) * ``` */ - verifyCredential( - credential: string | Credential.Credential, - ): Promise + verifyCredential(credential: string | Credential.Credential): Promise } /** Extracts the transport override from a method, if any. */ @@ -174,10 +172,9 @@ type ChallengeHandlers = { } /** A function that generates a Challenge object from intent options. */ -type ChallengeFn< - method extends Method.Method, - defaults extends Record, -> = (options: MethodFn.Options) => Challenge.Challenge +type ChallengeFn> = ( + options: MethodFn.Options, +) => Challenge.Challenge /** * Creates a server-side payment handler from methods. @@ -260,8 +257,7 @@ export function create< async function verifyCredentialFn( input: string | Credential.Credential, ): Promise { - const credential = - typeof input === 'string' ? Credential.deserialize(input) : input + const credential = typeof input === 'string' ? Credential.deserialize(input) : input // HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create()) if (!Challenge.verify(credential.challenge, { secretKey: secretKey! })) @@ -289,9 +285,7 @@ export function create< // The challenge already contains the request params (HMAC-bound), // so we use them directly — no need for the caller to re-supply. - const request = credential.challenge.request as z.input< - typeof mi.schema.request - > + const request = credential.challenge.request as z.input return mi.verify({ credential, request } as never) } From 3aeeb2516e2a5ecf804bd22c742ecbe43e0b571c Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Wed, 15 Apr 2026 12:53:51 -0700 Subject: [PATCH 4/5] fix: harden verifyCredential real-world flows --- src/server/Mppx.test-d.ts | 15 +- src/server/Mppx.test.ts | 409 ++++++++++++++++++++++++++++++++++-- src/server/Mppx.ts | 10 +- src/stripe/server/Charge.ts | 8 +- src/tempo/server/Charge.ts | 19 +- src/tempo/server/Session.ts | 9 +- 6 files changed, 431 insertions(+), 39 deletions(-) diff --git a/src/server/Mppx.test-d.ts b/src/server/Mppx.test-d.ts index 334ae070..7e06d41c 100644 --- a/src/server/Mppx.test-d.ts +++ b/src/server/Mppx.test-d.ts @@ -144,7 +144,7 @@ describe('Mppx type tests', () => { expectTypeOf(mppx.challenge.beta.charge).toBeFunction() }) - test('challenge functions return Challenge type', () => { + test('challenge functions return Promise', () => { const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey }) const challenge = mppx.challenge.alpha.charge({ @@ -154,11 +154,14 @@ describe('Mppx type tests', () => { recipient: '0x02', }) - expectTypeOf(challenge).toHaveProperty('id') - expectTypeOf(challenge).toHaveProperty('realm') - expectTypeOf(challenge).toHaveProperty('method') - expectTypeOf(challenge).toHaveProperty('intent') - expectTypeOf(challenge).toHaveProperty('request') + expectTypeOf(challenge).toMatchTypeOf>() + + type AwaitedChallenge = Awaited + expectTypeOf().toHaveProperty('id') + expectTypeOf().toHaveProperty('realm') + expectTypeOf().toHaveProperty('method') + expectTypeOf().toHaveProperty('intent') + expectTypeOf().toHaveProperty('request') }) test('verifyCredential exists and returns Promise', () => { diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index 72773dd8..a9ed74fe 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -1,9 +1,16 @@ import * as http from 'node:http' import { Challenge, Credential, Method, z } from 'mppx' -import { Mppx, Transport, tempo } from 'mppx/server' +import { + Mppx as Mppx_client, + session as tempo_session_client, + tempo as tempo_client, +} from 'mppx/client' +import { Mppx, stripe, Store, Transport, tempo } from 'mppx/server' +import { getTransactionReceipt } from 'viem/actions' import { describe, expect, test } from 'vp/test' import * as Http from '~test/Http.js' +import { deployEscrow } from '~test/tempo/session.js' import { accounts, asset, client } from '~test/tempo/viem.js' const realm = 'api.example.com' @@ -2827,14 +2834,14 @@ describe('challenge', () => { recipient: '0x0000000000000000000000000000000000000002', } - test('mppx.challenge.alpha.charge returns a valid Challenge object', () => { + test('mppx.challenge.alpha.charge returns a valid Challenge object', async () => { const mppx = Mppx.create({ methods: [alphaChargeServer, alphaSessionServer, betaChargeServer], realm, secretKey, }) - const challenge = mppx.challenge.alpha.charge(challengeOpts) + const challenge = await mppx.challenge.alpha.charge(challengeOpts) expect(challenge.method).toBe('alpha') expect(challenge.intent).toBe('charge') @@ -2845,14 +2852,14 @@ describe('challenge', () => { expect(challenge.id).toBeDefined() }) - test('mppx.challenge.alpha.session returns a valid Challenge object', () => { + test('mppx.challenge.alpha.session returns a valid Challenge object', async () => { const mppx = Mppx.create({ methods: [alphaChargeServer, alphaSessionServer, betaChargeServer], realm, secretKey, }) - const challenge = mppx.challenge.alpha.session({ + const challenge = await mppx.challenge.alpha.session({ amount: '500', currency: '0x0000000000000000000000000000000000000001', recipient: '0x0000000000000000000000000000000000000002', @@ -2865,38 +2872,38 @@ describe('challenge', () => { expect(challenge.request.unitType).toBe('token') }) - test('mppx.challenge.beta.charge returns challenge for a different method', () => { + test('mppx.challenge.beta.charge returns challenge for a different method', async () => { const mppx = Mppx.create({ methods: [alphaChargeServer, betaChargeServer], realm, secretKey, }) - const challenge = mppx.challenge.beta.charge(challengeOpts) + const challenge = await mppx.challenge.beta.charge(challengeOpts) expect(challenge.method).toBe('beta') expect(challenge.intent).toBe('charge') }) - test('challenge ID is HMAC-bound and verifiable', () => { + test('challenge ID is HMAC-bound and verifiable', async () => { const mppx = Mppx.create({ methods: [alphaChargeServer], realm, secretKey, }) - const challenge = mppx.challenge.alpha.charge(challengeOpts) + const challenge = await mppx.challenge.alpha.charge(challengeOpts) expect(Challenge.verify(challenge, { secretKey })).toBe(true) }) - test('challenge includes description and meta when provided', () => { + test('challenge includes description and meta when provided', async () => { const mppx = Mppx.create({ methods: [alphaChargeServer], realm, secretKey, }) - const challenge = mppx.challenge.alpha.charge({ + const challenge = await mppx.challenge.alpha.charge({ ...challengeOpts, description: 'Order #123', meta: { checkout_id: 'chk_abc' }, @@ -2906,7 +2913,7 @@ describe('challenge', () => { expect(challenge.opaque).toEqual({ checkout_id: 'chk_abc' }) }) - test('challenge applies schema transforms', () => { + test('challenge applies schema transforms', async () => { // Method with a z.transform that converts decimals const transformMethod = Method.from({ name: 'transform', @@ -2937,7 +2944,7 @@ describe('challenge', () => { const mppx = Mppx.create({ methods: [serverMethod], realm, secretKey }) - const challenge = mppx.challenge.transform.charge({ + const challenge = await mppx.challenge.transform.charge({ amount: '25.92', currency: '0x0000000000000000000000000000000000000001', decimals: 6, @@ -2948,6 +2955,53 @@ describe('challenge', () => { expect(challenge.request.amount).toBe('25920000') }) + test('challenge awaits async request hooks before creating the challenge', async () => { + const asyncMethod = Method.from({ + name: 'async', + intent: 'charge', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.pipe( + z.object({ + amount: z.string(), + chainId: z.optional(z.number()), + currency: z.string(), + decimals: z.number(), + recipient: z.string(), + }), + z.transform(({ amount, chainId, currency, decimals, recipient }) => ({ + amount: String(Number(amount) * 10 ** decimals), + currency, + methodDetails: { chainId }, + recipient, + })), + ), + }, + }) + + const asyncServer = Method.toServer(asyncMethod, { + async request({ request }) { + await Promise.resolve() + return { ...request, chainId: 42431 } + }, + async verify() { + return mockReceipt('async') + }, + }) + + const mppx = Mppx.create({ methods: [asyncServer], realm, secretKey }) + + const challenge = await mppx.challenge.async.charge({ + amount: '25.92', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + recipient: '0x0000000000000000000000000000000000000002', + }) + + expect(challenge.request.amount).toBe('25920000') + expect(challenge.request.methodDetails).toEqual({ chainId: 42431 }) + }) + test('challenge produced by mppx.challenge is accepted by the 402 handler', async () => { const mppx = Mppx.create({ methods: [alphaChargeServer], @@ -2956,7 +3010,7 @@ describe('challenge', () => { }) // Generate challenge via the new API - const challenge = mppx.challenge.alpha.charge(challengeOpts) + const challenge = await mppx.challenge.alpha.charge(challengeOpts) // Build a credential from it const credential = Credential.from({ challenge, payload: { token: 'valid' } }) @@ -3069,7 +3123,7 @@ describe('verifyCredential', () => { secretKey, }) - const challenge = mppx.challenge.alpha.charge(challengeOpts) + const challenge = await mppx.challenge.alpha.charge(challengeOpts) const credential = Credential.from({ challenge, payload: { token: 'valid' } }) const serialized = Credential.serialize(credential) @@ -3088,7 +3142,7 @@ describe('verifyCredential', () => { secretKey, }) - const challenge = mppx.challenge.alpha.charge(challengeOpts) + const challenge = await mppx.challenge.alpha.charge(challengeOpts) const credential = Credential.from({ challenge, payload: { token: 'valid' } }) const receipt = await mppx.verifyCredential(credential) @@ -3105,7 +3159,7 @@ describe('verifyCredential', () => { secretKey, }) - const challenge = mppx.challenge.alpha.session({ + const challenge = await mppx.challenge.alpha.session({ amount: '500', currency: '0x0000000000000000000000000000000000000001', recipient: '0x0000000000000000000000000000000000000002', @@ -3129,7 +3183,7 @@ describe('verifyCredential', () => { secretKey, }) - const challenge = mppx.challenge.beta.charge(challengeOpts) + const challenge = await mppx.challenge.beta.charge(challengeOpts) const credential = Credential.from({ challenge, payload: { token: 'valid' } }) const receipt = await mppx.verifyCredential(credential) @@ -3173,7 +3227,7 @@ describe('verifyCredential', () => { secretKey, }) - const challenge = mppx.challenge.alpha.charge({ + const challenge = await mppx.challenge.alpha.charge({ ...challengeOpts, expires: new Date(Date.now() - 1000).toISOString(), // already expired }) @@ -3189,7 +3243,7 @@ describe('verifyCredential', () => { secretKey, }) - const challenge = mppx.challenge.alpha.charge(challengeOpts) + const challenge = await mppx.challenge.alpha.charge(challengeOpts) const credential = Credential.from({ challenge, payload: { wrong_field: 123 }, // doesn't match z.object({ token: z.string() }) @@ -3265,7 +3319,7 @@ describe('verifyCredential', () => { const mppx = Mppx.create({ methods: [serverMethod], realm, secretKey }) // Generate challenge with human-readable amount - const challenge = mppx.challenge.transform.charge({ + const challenge = await mppx.challenge.transform.charge({ amount: '25.92', currency: '0x0000000000000000000000000000000000000001', decimals: 6, @@ -3283,6 +3337,317 @@ describe('verifyCredential', () => { expect(receipt.method).toBe('transform') }) + test('verifies a credential for a transformed built-in method', async () => { + const stripeClient = { + paymentIntents: { + create: async (input: { amount: number; currency: string }) => { + expect(input.amount).toBe(2592) + expect(input.currency).toBe('usd') + + return { + id: 'pi_123', + lastResponse: { headers: {} }, + status: 'succeeded', + } + }, + }, + } + + const mppx = Mppx.create({ + methods: [ + stripe.charge({ + client: stripeClient as never, + currency: 'usd', + decimals: 2, + networkId: 'internal', + paymentMethodTypes: ['card'], + }), + ], + realm, + secretKey, + }) + + const challenge = await mppx.challenge.stripe.charge({ + amount: '25.92', + }) + const credential = Credential.from({ + challenge, + payload: { spt: 'spt_test' }, + }) + + const receipt = await mppx.verifyCredential(credential) + + expect(receipt.status).toBe('success') + expect(receipt.method).toBe('stripe') + }) + + test('verifies a serialized credential for a transformed built-in method', async () => { + const stripeClient = { + paymentIntents: { + create: async (input: { amount: number; currency: string }) => { + expect(input.amount).toBe(2592) + expect(input.currency).toBe('usd') + + return { + id: 'pi_456', + lastResponse: { headers: {} }, + status: 'succeeded', + } + }, + }, + } + + const mppx = Mppx.create({ + methods: [ + stripe.charge({ + client: stripeClient as never, + currency: 'usd', + decimals: 2, + networkId: 'internal', + paymentMethodTypes: ['card'], + }), + ], + realm, + secretKey, + }) + + const challenge = await mppx.challenge.stripe.charge({ amount: '25.92' }) + const credential = Credential.from({ + challenge, + payload: { spt: 'spt_serialized' }, + }) + + const receipt = await mppx.verifyCredential(Credential.serialize(credential)) + + expect(receipt.status).toBe('success') + expect(receipt.method).toBe('stripe') + }) + + test('verifies a zero-amount proof credential created from a real 402 response', async () => { + const server = Mppx.create({ + methods: [ + tempo.charge({ + account: accounts[0], + currency: asset, + getClient: () => client, + }), + ], + realm, + secretKey, + }) + const clientMppx = Mppx_client.create({ + polyfill: false, + methods: [ + tempo_client.charge({ + account: accounts[1], + getClient: () => client, + }), + ], + }) + + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx.toNodeListener(server.charge({ amount: '0' }))(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response = await fetch(httpServer.url) + expect(response.status).toBe(402) + + const serializedCredential = await clientMppx.createCredential(response) + const proofCredential = Credential.deserialize(serializedCredential) + expect(proofCredential.payload).toMatchObject({ type: 'proof' }) + + const receipt = await server.verifyCredential(serializedCredential) + + expect(receipt.status).toBe('success') + expect(receipt.method).toBe('tempo') + + httpServer.close() + }) + + test('verifies a sponsored tempo credential created from a real 402 response', async () => { + const server = Mppx.create({ + methods: [ + tempo.charge({ + account: accounts[0], + currency: asset, + feePayer: true, + getClient: () => client, + }), + ], + realm, + secretKey, + }) + const clientMppx = Mppx_client.create({ + polyfill: false, + methods: [ + tempo_client.charge({ + account: accounts[1], + getClient: () => client, + }), + ], + }) + + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx.toNodeListener(server.charge({ amount: '1' }))(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response = await fetch(httpServer.url) + expect(response.status).toBe(402) + + const serializedCredential = await clientMppx.createCredential(response, { mode: 'pull' }) + const transactionCredential = Credential.deserialize(serializedCredential) + expect(transactionCredential.payload).toMatchObject({ type: 'transaction' }) + + const receipt = await server.verifyCredential(serializedCredential) + + expect(receipt.status).toBe('success') + expect(receipt.method).toBe('tempo') + + const txReceipt = await getTransactionReceipt(client, { + hash: receipt.reference as `0x${string}`, + }) + expect((txReceipt as { feePayer?: string }).feePayer).toBe(accounts[0].address.toLowerCase()) + + httpServer.close() + }) + + test('verifies real session open and voucher credentials created from 402 responses', async () => { + const escrowContract = await deployEscrow() + const server = Mppx.create({ + methods: [ + tempo.session({ + store: Store.memory(), + getClient: () => client, + account: accounts[0], + currency: asset, + escrowContract, + chainId: client.chain!.id, + }), + ], + realm, + secretKey, + }) + const clientMppx = Mppx_client.create({ + polyfill: false, + methods: [ + tempo_session_client({ + account: accounts[1], + deposit: '10', + getClient: () => client, + }), + ], + }) + + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx.toNodeListener( + server.session({ amount: '1', unitType: 'request' }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const openChallengeResponse = await fetch(httpServer.url) + expect(openChallengeResponse.status).toBe(402) + + const serializedOpenCredential = await clientMppx.createCredential(openChallengeResponse) + const openCredential = Credential.deserialize(serializedOpenCredential) + expect(openCredential.payload).toMatchObject({ action: 'open' }) + + const openReceipt = await server.verifyCredential(serializedOpenCredential) + + expect(openReceipt.status).toBe('success') + expect(openReceipt.method).toBe('tempo') + + const voucherChallengeResponse = await fetch(httpServer.url) + expect(voucherChallengeResponse.status).toBe(402) + + const serializedVoucherCredential = await clientMppx.createCredential(voucherChallengeResponse) + const voucherCredential = Credential.deserialize(serializedVoucherCredential) + expect(voucherCredential.payload).toMatchObject({ action: 'voucher' }) + + const voucherReceipt = await server.verifyCredential(serializedVoucherCredential) + + expect(voucherReceipt.status).toBe('success') + expect(voucherReceipt.method).toBe('tempo') + expect(voucherReceipt.reference).toBe(openReceipt.reference) + + httpServer.close() + }) + + test('verifies a sponsored tempo credential created by the real client', async () => { + const server = Mppx.create({ + methods: [ + tempo.charge({ + account: accounts[0], + currency: asset, + feePayer: true, + getClient: () => client, + }), + ], + realm, + secretKey, + }) + + const challenge = await server.challenge.tempo.charge({ amount: '1' }) + const clientMethod = tempo_client.charge({ + account: accounts[1], + getClient: () => client, + }) + const credential = await clientMethod.createCredential({ + challenge: challenge as Parameters[0]['challenge'], + context: { mode: 'pull' }, + }) + + const receipt = await server.verifyCredential(credential) + + expect(receipt.status).toBe('success') + expect(receipt.method).toBe('tempo') + + const txReceipt = await getTransactionReceipt(client, { + hash: receipt.reference as `0x${string}`, + }) + expect((txReceipt as { feePayer?: string }).feePayer).toBe(accounts[0].address.toLowerCase()) + }) + + test('verifies a sponsored tempo credential object created by the real client', async () => { + const server = Mppx.create({ + methods: [ + tempo.charge({ + account: accounts[0], + currency: asset, + feePayer: true, + getClient: () => client, + }), + ], + realm, + secretKey, + }) + + const challenge = await server.challenge.tempo.charge({ amount: '1' }) + const clientMethod = tempo_client.charge({ + account: accounts[1], + getClient: () => client, + }) + const serializedCredential = await clientMethod.createCredential({ + challenge: challenge as Parameters[0]['challenge'], + context: { mode: 'pull' }, + }) + + const receipt = await server.verifyCredential(Credential.deserialize(serializedCredential)) + + expect(receipt.status).toBe('success') + expect(receipt.method).toBe('tempo') + + const txReceipt = await getTransactionReceipt(client, { + hash: receipt.reference as `0x${string}`, + }) + expect((txReceipt as { feePayer?: string }).feePayer).toBe(accounts[0].address.toLowerCase()) + }) + test('challenge + verifyCredential round-trip with serialized string', async () => { const mppx = Mppx.create({ methods: [alphaChargeServer, alphaSessionServer], @@ -3291,7 +3656,7 @@ describe('verifyCredential', () => { }) // Generate, serialize, verify — the full UCP flow - const challenge = mppx.challenge.alpha.charge(challengeOpts) + const challenge = await mppx.challenge.alpha.charge(challengeOpts) const credential = Credential.from({ challenge, payload: { token: 'valid' } }) const serialized = Credential.serialize(credential) diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 2a3e1514..1d9d6710 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -78,7 +78,7 @@ export type Mppx< * * @example * ```ts - * const challenge = mppx.challenge.tempo.charge({ amount: '25.92' }) + * const challenge = await mppx.challenge.tempo.charge({ amount: '25.92' }) * ``` */ challenge: ChallengeHandlers> @@ -174,7 +174,7 @@ type ChallengeHandlers = { /** A function that generates a Challenge object from intent options. */ type ChallengeFn> = ( options: MethodFn.Options, -) => Challenge.Challenge +) => Promise /** * Creates a server-side payment handler from methods. @@ -585,10 +585,10 @@ function createChallengeFn(parameters: { realm: string | undefined request?: Method.RequestFn secretKey: string -}): (options: Record) => Challenge.Challenge { +}): (options: Record) => Promise { const { defaults, method, realm, secretKey } = parameters - return (options) => { + return async (options) => { const { description, meta, ...rest } = options as { description?: string expires?: string @@ -602,7 +602,7 @@ function createChallengeFn(parameters: { // Transform request if method provides a `request` function. const request = ( parameters.request - ? (parameters.request as (opts: { request: unknown }) => unknown)({ + ? await (parameters.request as (opts: { request: unknown }) => unknown)({ request: merged, }) : merged diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index cef42abe..315e7faa 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -94,7 +94,13 @@ export function charge(parameters: p async verify({ credential, request }) { const { challenge } = credential - const resolvedRequest = Methods.charge.schema.request.parse(request) + const resolvedRequest = (() => { + const parsed = Methods.charge.schema.request.safeParse(request) + if (parsed.success) return parsed.data + // verifyCredential() passes the HMAC-bound challenge request, which is + // already in canonical output form and should not be transformed again. + return request as unknown as z.output + })() Expires.assert(challenge.expires, challenge.id) diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index fa78bdcd..2b437f44 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -155,13 +155,24 @@ export function charge( async verify({ credential, request }) { const { challenge } = credential - const resolvedRequest = Methods.charge.schema.request.parse(request) + const resolvedRequest = (() => { + const parsed = Methods.charge.schema.request.safeParse(request) + if (parsed.success) return parsed.data + // verifyCredential() passes the HMAC-bound challenge request, which is + // already in canonical output form and should not be transformed again. + return request as unknown as z.output + })() const chainId = resolvedRequest.methodDetails?.chainId ?? request.chainId - const feePayer = typeof request.feePayer === 'object' ? request.feePayer : undefined const client = await getClient({ chainId }) const { amount, methodDetails } = resolvedRequest + const feePayerAccount = + typeof request.feePayer === 'object' + ? request.feePayer + : methodDetails?.feePayer === true + ? feePayer + : undefined const expires = challenge.expires const supportedModes = methodDetails?.supportedModes as | readonly Methods.ChargeMode[] @@ -307,9 +318,9 @@ export function charge( const resolvedFeeToken = transaction.feeToken ?? expectedFeeToken const serializedTransaction_final = await (async () => { - if (feePayer && methodDetails?.feePayer !== false) { + if (feePayerAccount && methodDetails?.feePayer !== false) { const sponsored = FeePayer.prepareSponsoredTransaction({ - account: feePayer, + account: feePayerAccount, challengeExpires: expires, chainId: chainId ?? client.chain!.id, details: { amount, currency, recipient }, diff --git a/src/tempo/server/Session.ts b/src/tempo/server/Session.ts index 94aa8131..19dc45cb 100644 --- a/src/tempo/server/Session.ts +++ b/src/tempo/server/Session.ts @@ -35,6 +35,7 @@ import type { LooseOmit, NoExtraKeys } from '../../internal/types.js' import * as Method from '../../Method.js' import * as Store from '../../Store.js' import * as Client from '../../viem/Client.js' +import type * as z from '../../zod.js' import * as Account from '../internal/account.js' import * as defaults from '../internal/defaults.js' import type * as types from '../internal/types.js' @@ -182,7 +183,13 @@ export function session( async verify({ credential, request }) { const { challenge, payload } = credential as Credential.Credential - const resolvedRequest = Methods.session.schema.request.parse(request) + const resolvedRequest = (() => { + const parsed = Methods.session.schema.request.safeParse(request) + if (parsed.success) return parsed.data + // verifyCredential() passes the HMAC-bound challenge request, which is + // already in canonical output form and should not be transformed again. + return request as unknown as z.output + })() const methodDetails = resolvedRequest.methodDetails as SessionMethodDetails const client = await getClient({ chainId: methodDetails.chainId }) From 95fbbee84ea93f97c769e7d1d2b8e7dc4edb043a Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Wed, 15 Apr 2026 13:45:02 -0700 Subject: [PATCH 5/5] test: isolate CLI output from local skill metadata --- src/cli/cli.test.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 8a611c77..b7b5c78a 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -28,18 +28,22 @@ import cli from './cli.js' const testPrivateKey = generatePrivateKey() const testAccount = privateKeyToAccount(testPrivateKey) +const testXdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-cli-xdg-')) + +afterAll(() => { + fs.rmSync(testXdgDataHome, { recursive: true, force: true }) +}) async function serve(argv: string[], options?: { env?: Record }) { let output = '' let stderr = '' let exitCode: number | undefined const saved: Record = {} - if (options?.env) { - for (const [key, value] of Object.entries(options.env)) { - saved[key] = process.env[key] - if (value === undefined) delete process.env[key] - else process.env[key] = value - } + const env = { XDG_DATA_HOME: testXdgDataHome, ...options?.env } + for (const [key, value] of Object.entries(env)) { + saved[key] = process.env[key] + if (value === undefined) delete process.env[key] + else process.env[key] = value } const origStdoutWrite = process.stdout.write const origStderrWrite = process.stderr.write @@ -1053,7 +1057,11 @@ describe('stripe charge', () => { describe.skipIf(!!process.env.CI)('account', () => { const binPath = path.resolve(import.meta.dirname, '../bin.ts') const cwd = path.resolve(import.meta.dirname, '../..') - const accountEnv = { ...process.env, NODE_NO_WARNINGS: '1' } + const accountEnv = { + ...process.env, + NODE_NO_WARNINGS: '1', + XDG_DATA_HOME: testXdgDataHome, + } const prefix = `__mppx_test_${Date.now()}` const createdAccounts: string[] = []