mppx 0.6.14 → 0.6.16

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/Challenge.d.ts +7 -3
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +15 -9
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Credential.d.ts +2 -2
  7. package/dist/Credential.d.ts.map +1 -1
  8. package/dist/Credential.js +31 -16
  9. package/dist/Credential.js.map +1 -1
  10. package/dist/client/Mppx.d.ts.map +1 -1
  11. package/dist/client/Mppx.js +3 -0
  12. package/dist/client/Mppx.js.map +1 -1
  13. package/dist/client/internal/Fetch.d.ts.map +1 -1
  14. package/dist/client/internal/Fetch.js +3 -0
  15. package/dist/client/internal/Fetch.js.map +1 -1
  16. package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
  17. package/dist/mcp-sdk/client/McpClient.js +3 -0
  18. package/dist/mcp-sdk/client/McpClient.js.map +1 -1
  19. package/dist/server/Mppx.d.ts.map +1 -1
  20. package/dist/server/Mppx.js +19 -9
  21. package/dist/server/Mppx.js.map +1 -1
  22. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  23. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  24. package/dist/stripe/server/internal/html.gen.js +1 -1
  25. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  26. package/dist/tempo/Proof.d.ts +5 -0
  27. package/dist/tempo/Proof.d.ts.map +1 -1
  28. package/dist/tempo/Proof.js +5 -1
  29. package/dist/tempo/Proof.js.map +1 -1
  30. package/dist/tempo/internal/proof.d.ts +2 -2
  31. package/dist/tempo/internal/proof.d.ts.map +1 -1
  32. package/dist/tempo/internal/proof.js +2 -2
  33. package/dist/tempo/internal/proof.js.map +1 -1
  34. package/dist/tempo/server/Charge.d.ts.map +1 -1
  35. package/dist/tempo/server/Charge.js +57 -17
  36. package/dist/tempo/server/Charge.js.map +1 -1
  37. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  38. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  39. package/dist/tempo/server/internal/html.gen.js +1 -1
  40. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  41. package/package.json +4 -4
  42. package/src/Challenge.test.ts +25 -9
  43. package/src/Challenge.ts +21 -10
  44. package/src/Credential.test.ts +64 -2
  45. package/src/Credential.ts +35 -19
  46. package/src/client/Mppx.test.ts +39 -3
  47. package/src/client/Mppx.ts +2 -0
  48. package/src/client/internal/Fetch.test.ts +22 -3
  49. package/src/client/internal/Fetch.ts +2 -0
  50. package/src/mcp-sdk/client/McpClient.test.ts +39 -2
  51. package/src/mcp-sdk/client/McpClient.ts +3 -0
  52. package/src/middlewares/hono.test.ts +2 -2
  53. package/src/proxy/Proxy.test.ts +2 -2
  54. package/src/server/Mppx.test.ts +28 -8
  55. package/src/server/Mppx.ts +23 -9
  56. package/src/stripe/server/internal/html/package.json +1 -1
  57. package/src/stripe/server/internal/html.gen.ts +1 -1
  58. package/src/tempo/Proof.test-d.ts +4 -0
  59. package/src/tempo/Proof.test.ts +9 -0
  60. package/src/tempo/Proof.ts +6 -1
  61. package/src/tempo/internal/proof.test.ts +4 -4
  62. package/src/tempo/internal/proof.ts +2 -2
  63. package/src/tempo/server/Charge.test.ts +476 -0
  64. package/src/tempo/server/Charge.ts +61 -17
  65. package/src/tempo/server/internal/html/main.ts +10 -3
  66. package/src/tempo/server/internal/html/package.json +1 -1
  67. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,wxucAAwxuc,CAAA"}
1
+ {"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,206tBAA206tB,CAAA"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.6.14",
4
+ "version": "0.6.16",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "files": [
@@ -105,7 +105,7 @@
105
105
  "@modelcontextprotocol/sdk": ">=1.25.0",
106
106
  "elysia": ">=1",
107
107
  "express": ">=5",
108
- "hono": ">=4.12.14",
108
+ "hono": ">=4.12.16",
109
109
  "viem": ">=2.47.5"
110
110
  },
111
111
  "peerDependenciesMeta": {
@@ -123,8 +123,8 @@
123
123
  }
124
124
  },
125
125
  "dependencies": {
126
- "incur": "^0.3.25",
127
- "ox": "0.14.18",
126
+ "incur": "^0.4.5",
127
+ "ox": "0.14.20",
128
128
  "zod": "^4.3.6"
129
129
  },
130
130
  "repository": {
@@ -693,7 +693,8 @@ describe('opaque', () => {
693
693
  meta: { pi: 'pi_3abc123XYZ' },
694
694
  })
695
695
 
696
- expect(challenge.opaque).toEqual({ pi: 'pi_3abc123XYZ' })
696
+ expect(challenge.meta).toEqual({ pi: 'pi_3abc123XYZ' })
697
+ expect(challenge.opaque).toBe('eyJwaSI6InBpXzNhYmMxMjNYWVoifQ')
697
698
  expect((challenge.request as Record<string, unknown>).opaque).toBeUndefined()
698
699
  })
699
700
 
@@ -710,10 +711,11 @@ describe('opaque', () => {
710
711
  meta: { payment_intent: 'pi_3abc123XYZ' },
711
712
  })
712
713
 
713
- expect(challenge.opaque).toEqual({ payment_intent: 'pi_3abc123XYZ' })
714
+ expect(challenge.meta).toEqual({ payment_intent: 'pi_3abc123XYZ' })
715
+ expect(challenge.opaque).toBe('eyJwYXltZW50X2ludGVudCI6InBpXzNhYmMxMjNYWVoifQ')
714
716
  })
715
717
 
716
- test('behavior: challenge.opaque is undefined when no meta', () => {
718
+ test('behavior: challenge opaque and meta are undefined when no meta', () => {
717
719
  const challenge = Challenge.from({
718
720
  id: 'abc123',
719
721
  realm: 'api.example.com',
@@ -723,9 +725,10 @@ describe('opaque', () => {
723
725
  })
724
726
 
725
727
  expect(challenge.opaque).toBeUndefined()
728
+ expect(challenge.meta).toBeUndefined()
726
729
  })
727
730
 
728
- test('behavior: opaque roundtrips through serialize/deserialize', () => {
731
+ test('behavior: opaque roundtrips through serialize/deserialize as a raw string', () => {
729
732
  const original = Challenge.from({
730
733
  id: 'abc123',
731
734
  realm: 'api.example.com',
@@ -738,10 +741,21 @@ describe('opaque', () => {
738
741
  const header = Challenge.serialize(original)
739
742
  const deserialized = Challenge.deserialize(header)
740
743
 
741
- expect(deserialized.opaque).toEqual({ pi: 'pi_3abc123XYZ', deposit: 'dep_456' })
744
+ expect(deserialized.opaque).toBe('eyJkZXBvc2l0IjoiZGVwXzQ1NiIsInBpIjoicGlfM2FiYzEyM1hZWiJ9')
745
+ expect(deserialized.meta).toBeUndefined()
742
746
  })
743
747
 
744
- test('behavior: meta with empty object produces opaque: {}', () => {
748
+ test('behavior: deserialize does not parse non-json opaque', () => {
749
+ const header =
750
+ 'Payment id="abc123", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMDAwIn0", opaque="eXkgd3Jvbmc"'
751
+
752
+ const challenge = Challenge.deserialize(header)
753
+
754
+ expect(challenge.opaque).toBe('eXkgd3Jvbmc')
755
+ expect(challenge.meta).toBeUndefined()
756
+ })
757
+
758
+ test('behavior: meta with empty object produces opaque string and meta: {}', () => {
745
759
  const challenge = Challenge.from({
746
760
  id: 'abc123',
747
761
  realm: 'api.example.com',
@@ -751,7 +765,8 @@ describe('opaque', () => {
751
765
  meta: {},
752
766
  })
753
767
 
754
- expect(challenge.opaque).toEqual({})
768
+ expect(challenge.meta).toEqual({})
769
+ expect(challenge.opaque).toBe('e30')
755
770
  })
756
771
 
757
772
  test('hmac: opaque affects challenge ID', () => {
@@ -844,7 +859,8 @@ describe('opaque', () => {
844
859
 
845
860
  const tampered = {
846
861
  ...challenge,
847
- opaque: { pi: 'pi_TAMPERED' },
862
+ meta: { pi: 'pi_TAMPERED' },
863
+ opaque: 'eyJwaSI6InBpX1RBTVBFUkVEIn0',
848
864
  }
849
865
  expect(Challenge.verify(tampered, { secretKey: 'my-secret' })).toBe(false)
850
866
  })
@@ -863,7 +879,7 @@ describe('opaque', () => {
863
879
  },
864
880
  })
865
881
 
866
- expect(challenge.opaque).toEqual({
882
+ expect(challenge.meta).toEqual({
867
883
  payment_intent: 'pi_3abc123XYZ',
868
884
  customer: 'cus_xyz',
869
885
  session_id: 'sess_abc',
package/src/Challenge.ts CHANGED
@@ -29,8 +29,10 @@ export const Schema = z.object({
29
29
  intent: z.string(),
30
30
  /** Payment method (e.g., "tempo", "stripe"). */
31
31
  method: z.string(),
32
- /** Optional server-defined correlation data. Flat string-to-string map; clients MUST NOT modify. */
33
- opaque: z.optional(z.record(z.string(), z.string())),
32
+ /** Optional parsed server-defined correlation data. */
33
+ meta: z.optional(z.record(z.string(), z.string())),
34
+ /** Optional server-defined correlation data. Raw base64url string; clients MUST NOT modify. */
35
+ opaque: z.optional(z.string()),
34
36
  /** Server realm (e.g., hostname). */
35
37
  realm: z.string(),
36
38
  /** Method-specific request data. */
@@ -128,8 +130,13 @@ export function from<
128
130
  } = parameters
129
131
 
130
132
  const expires = parameters.expires as string
133
+ const opaque =
134
+ parameters.opaque ?? (meta !== undefined ? PaymentRequest.serialize(meta) : undefined)
131
135
  const id = secretKey
132
- ? computeId({ ...parameters, expires, ...(meta && { opaque: meta }) }, { secretKey })
136
+ ? computeId(
137
+ { ...parameters, expires, ...(meta !== undefined && { meta }), opaque },
138
+ { secretKey },
139
+ )
133
140
  : (parameters as { id: string }).id
134
141
 
135
142
  return Schema.parse({
@@ -141,7 +148,8 @@ export function from<
141
148
  ...(description && { description }),
142
149
  ...(digest && { digest }),
143
150
  ...(expires && { expires }),
144
- ...(meta && { opaque: meta }),
151
+ ...(meta !== undefined && { meta }),
152
+ ...(opaque !== undefined && { opaque }),
145
153
  }) as from.ReturnType<parameters, methods>
146
154
  }
147
155
 
@@ -170,6 +178,8 @@ export declare namespace from {
170
178
  intent: string
171
179
  /** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */
172
180
  meta?: Record<string, string> | undefined
181
+ /** Optional raw base64url-encoded server-defined correlation data. Clients MUST NOT modify. */
182
+ opaque?: string | undefined
173
183
  /** Payment method (e.g., "tempo", "stripe"). */
174
184
  method: string
175
185
  /** Server realm (e.g., hostname). */
@@ -294,8 +304,9 @@ export function serialize(challenge: Challenge): string {
294
304
  if (challenge.description !== undefined) parts.push(`description="${challenge.description}"`)
295
305
  if (challenge.digest !== undefined) parts.push(`digest="${challenge.digest}"`)
296
306
  if (challenge.expires !== undefined) parts.push(`expires="${challenge.expires}"`)
297
- if (challenge.opaque !== undefined)
298
- parts.push(`opaque="${PaymentRequest.serialize(challenge.opaque)}"`)
307
+ if (challenge.opaque !== undefined) parts.push(`opaque="${challenge.opaque}"`)
308
+ else if (challenge.meta !== undefined)
309
+ parts.push(`opaque="${PaymentRequest.serialize(challenge.meta)}"`)
299
310
 
300
311
  return `Payment ${parts.join(', ')}`
301
312
  }
@@ -335,7 +346,7 @@ export function deserialize<const methods extends readonly Method.Method[] | und
335
346
  {
336
347
  ...rest,
337
348
  request: PaymentRequest.deserialize(request),
338
- ...(opaque && { meta: PaymentRequest.deserialize(opaque) as Record<string, string> }),
349
+ ...(opaque && { opaque }),
339
350
  } as from.Parameters,
340
351
  options,
341
352
  )
@@ -604,9 +615,9 @@ export declare namespace verify {
604
615
  }
605
616
  }
606
617
 
607
- /** Alias for `challenge.opaque`. Extracts server-defined correlation data from a challenge. */
618
+ /** Extracts parsed server-defined correlation data from a challenge when available. */
608
619
  export function meta(challenge: Challenge): Record<string, string> | undefined {
609
- return challenge.opaque
620
+ return challenge.meta
610
621
  }
611
622
 
612
623
  /**
@@ -631,7 +642,7 @@ function idBindingInput(challenge: Omit<Challenge, 'id'>): string {
631
642
  PaymentRequest.serialize(challenge.request),
632
643
  challenge.expires ?? '',
633
644
  challenge.digest ?? '',
634
- challenge.opaque ? PaymentRequest.serialize(challenge.opaque) : '',
645
+ challenge.opaque ?? (challenge.meta ? PaymentRequest.serialize(challenge.meta) : ''),
635
646
  ].join('|')
636
647
  }
637
648
 
@@ -111,6 +111,24 @@ describe('serialize', () => {
111
111
 
112
112
  expect(parsed.challenge.opaque).toBe('eyJwaSI6InBpXzNhYmMxMjNYWVoifQ')
113
113
  })
114
+
115
+ test('behavior: echoes raw opaque unchanged', () => {
116
+ const rawChallenge = Challenge.deserialize(
117
+ 'Payment id="opaque123", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwIn0", opaque="eXkgd3Jvbmc"',
118
+ )
119
+ const credential = Credential.from({
120
+ challenge: rawChallenge,
121
+ payload: { signature: '0x1234' },
122
+ })
123
+
124
+ const header = Credential.serialize(credential)
125
+ const encoded = header.replace(/^Payment\s+/i, '')
126
+ const parsed = JSON.parse(Base64.toString(encoded)) as {
127
+ challenge: { opaque?: unknown }
128
+ }
129
+
130
+ expect(parsed.challenge.opaque).toBe('eXkgd3Jvbmc')
131
+ })
114
132
  })
115
133
 
116
134
  describe('deserialize', () => {
@@ -175,7 +193,31 @@ describe('deserialize', () => {
175
193
 
176
194
  const credential = Credential.deserialize(`Payment ${encoded}`)
177
195
 
178
- expect(credential.challenge.opaque).toEqual({ pi: 'pi_3abc123XYZ' })
196
+ expect(credential.challenge.opaque).toBe('eyJwaSI6InBpXzNhYmMxMjNYWVoifQ')
197
+ expect(credential.challenge.meta).toBeUndefined()
198
+ expect(credential.challenge.request).toEqual({ amount: '1000' })
199
+ })
200
+
201
+ test('behavior: preserves non-json opaque string credentials', () => {
202
+ const encoded = Base64.fromString(
203
+ JSON.stringify({
204
+ challenge: {
205
+ id: 'opaque123',
206
+ intent: 'charge',
207
+ method: 'tempo',
208
+ opaque: 'eXkgd3Jvbmc',
209
+ realm: 'api.example.com',
210
+ request: 'eyJhbW91bnQiOiIxMDAwIn0',
211
+ },
212
+ payload: { signature: '0x1234' },
213
+ }),
214
+ { pad: false, url: true },
215
+ )
216
+
217
+ const credential = Credential.deserialize(`Payment ${encoded}`)
218
+
219
+ expect(credential.challenge.opaque).toBe('eXkgd3Jvbmc')
220
+ expect(credential.challenge.meta).toBeUndefined()
179
221
  expect(credential.challenge.request).toEqual({ amount: '1000' })
180
222
  })
181
223
 
@@ -197,7 +239,27 @@ describe('deserialize', () => {
197
239
 
198
240
  const credential = Credential.deserialize(`Payment ${encoded}`)
199
241
 
200
- expect(credential.challenge.opaque).toEqual({ pi: 'pi_3abc123XYZ' })
242
+ expect(credential.challenge.opaque).toBe('eyJwaSI6InBpXzNhYmMxMjNYWVoifQ')
243
+ expect(credential.challenge.meta).toEqual({ pi: 'pi_3abc123XYZ' })
244
+ })
245
+
246
+ test('error: rejects legacy object-shaped opaque with non-string values', () => {
247
+ const encoded = Base64.fromString(
248
+ JSON.stringify({
249
+ challenge: {
250
+ id: 'opaque123',
251
+ intent: 'charge',
252
+ method: 'tempo',
253
+ opaque: { pi: 123 },
254
+ realm: 'api.example.com',
255
+ request: 'eyJhbW91bnQiOiIxMDAwIn0',
256
+ },
257
+ payload: { signature: '0x1234' },
258
+ }),
259
+ { pad: false, url: true },
260
+ )
261
+
262
+ expect(() => Credential.deserialize(`Payment ${encoded}`)).toThrow('Invalid base64url or JSON.')
201
263
  })
202
264
 
203
265
  test('error: throws for missing Payment scheme', () => {
package/src/Credential.ts CHANGED
@@ -63,27 +63,20 @@ export function deserialize<payload = unknown>(value: string): Credential<payloa
63
63
  try {
64
64
  const json = Base64.toString(prefixMatch[1])
65
65
  const parsed = JSON.parse(json) as {
66
- challenge: Omit<Challenge.Challenge, 'opaque' | 'request'> & {
67
- opaque?: Record<string, string> | string
66
+ challenge: Omit<Challenge.Challenge, 'meta' | 'opaque' | 'request'> & {
67
+ opaque?: unknown
68
68
  request: string
69
69
  }
70
70
  payload: payload
71
71
  source?: string
72
72
  }
73
+ const { opaque: challengeOpaque, request, ...challengeFields } = parsed.challenge
74
+ const { meta, opaque } = normalizeCredentialOpaque(challengeOpaque)
73
75
  const challenge = Challenge.Schema.parse({
74
- ...parsed.challenge,
75
- ...(parsed.challenge.opaque !== undefined && {
76
- // TODO: Drop the legacy object-shaped `opaque` fallback after old mppx
77
- // clients are no longer in circulation. Older mppx versions echoed
78
- // `opaque` as an expanded JSON object in credentials, but the Payment
79
- // auth spec requires clients to return the original base64url string
80
- // unchanged in the credential challenge object.
81
- opaque:
82
- typeof parsed.challenge.opaque === 'string'
83
- ? (PaymentRequest.deserialize(parsed.challenge.opaque) as Record<string, string>)
84
- : parsed.challenge.opaque,
85
- }),
86
- request: PaymentRequest.deserialize(parsed.challenge.request),
76
+ ...challengeFields,
77
+ ...(meta !== undefined && { meta }),
78
+ ...(opaque !== undefined && { opaque }),
79
+ request: PaymentRequest.deserialize(request),
87
80
  })
88
81
  return {
89
82
  challenge,
@@ -95,6 +88,28 @@ export function deserialize<payload = unknown>(value: string): Credential<payloa
95
88
  }
96
89
  }
97
90
 
91
+ function normalizeCredentialOpaque(value: unknown): {
92
+ meta?: Record<string, string> | undefined
93
+ opaque?: string | undefined
94
+ } {
95
+ if (value === undefined) return {}
96
+ if (typeof value === 'string') return { opaque: value }
97
+ if (!isStringRecord(value)) throw new Error('Invalid opaque.')
98
+
99
+ // Older mppx clients emitted credential challenge `opaque` as an expanded
100
+ // object. Keep accepting that legacy shape, but normalize it back to the
101
+ // spec-required base64url string.
102
+ return {
103
+ meta: value,
104
+ opaque: PaymentRequest.serialize(value),
105
+ }
106
+ }
107
+
108
+ function isStringRecord(value: unknown): value is Record<string, string> {
109
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
110
+ return Object.values(value).every((entry) => typeof entry === 'string')
111
+ }
112
+
98
113
  /**
99
114
  * Creates a credential from the given parameters.
100
115
  *
@@ -156,8 +171,8 @@ export function fromRequest<payload = unknown>(request: Request): Credential<pay
156
171
 
157
172
  /**
158
173
  * Serializes a credential to the Authorization header format.
159
- * When present, `challenge.opaque` is encoded as the base64url string required
160
- * by the Payment auth credential format.
174
+ * When present, `challenge.opaque` is emitted unchanged as the base64url string
175
+ * required by the Payment auth credential format.
161
176
  *
162
177
  * @param credential - The credential to serialize.
163
178
  * @returns A string suitable for the Authorization header value.
@@ -171,11 +186,12 @@ export function fromRequest<payload = unknown>(request: Request): Credential<pay
171
186
  * ```
172
187
  */
173
188
  export function serialize(credential: Credential): string {
174
- const { opaque, request, ...challenge } = credential.challenge
189
+ const { meta, opaque, request, ...challenge } = credential.challenge
190
+ const wireOpaque = opaque ?? (meta !== undefined ? PaymentRequest.serialize(meta) : undefined)
175
191
  const wire = {
176
192
  challenge: {
177
193
  ...challenge,
178
- ...(opaque !== undefined && { opaque: PaymentRequest.serialize(opaque) }),
194
+ ...(wireOpaque !== undefined && { opaque: wireOpaque }),
179
195
  request: PaymentRequest.serialize(request),
180
196
  },
181
197
  payload: credential.payload,
@@ -1,8 +1,8 @@
1
- import { Challenge, Credential, Mcp, Method, Receipt } from 'mppx'
1
+ import { Challenge, Credential, Errors, Mcp, Method, Receipt } from 'mppx'
2
2
  import { Mppx, Transport, tempo } from 'mppx/client'
3
3
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
4
4
  import { Methods } from 'mppx/tempo'
5
- import { afterEach, describe, expect, test } from 'vp/test'
5
+ import { afterEach, describe, expect, test, vi } from 'vp/test'
6
6
  import * as Http from '~test/Http.js'
7
7
  import { accounts, asset, client } from '~test/tempo/viem.js'
8
8
 
@@ -134,6 +134,42 @@ describe('createCredential', () => {
134
134
  )
135
135
  })
136
136
 
137
+ test('behavior: rejects expired challenges before creating credential', async () => {
138
+ const createCredential = vi.fn(async ({ challenge }) =>
139
+ Credential.serialize({
140
+ challenge,
141
+ payload: { signature: '0xsignature', type: 'transaction' },
142
+ }),
143
+ )
144
+ const method = Method.toClient(Methods.charge, { createCredential })
145
+ const mppx = Mppx.create({
146
+ polyfill: false,
147
+ methods: [method],
148
+ })
149
+
150
+ const challenge = Challenge.fromMethod(Methods.charge, {
151
+ realm,
152
+ secretKey,
153
+ expires: new Date(Date.now() - 60_000).toISOString(),
154
+ request: {
155
+ amount: '1000',
156
+ currency: '0x1234567890123456789012345678901234567890',
157
+ decimals: 6,
158
+ recipient: '0x1234567890123456789012345678901234567890',
159
+ },
160
+ })
161
+
162
+ const response = new Response(null, {
163
+ status: 402,
164
+ headers: {
165
+ 'WWW-Authenticate': Challenge.serialize(challenge),
166
+ },
167
+ })
168
+
169
+ await expect(mppx.createCredential(response)).rejects.toThrow(Errors.PaymentExpiredError)
170
+ expect(createCredential).not.toHaveBeenCalled()
171
+ })
172
+
137
173
  test('behavior: routes to correct method with multiple methods', async () => {
138
174
  const stripeCharge = Method.from({
139
175
  name: 'stripe',
@@ -165,7 +201,6 @@ describe('createCredential', () => {
165
201
  realm,
166
202
  method: 'stripe',
167
203
  intent: 'charge',
168
- expires: new Date(Date.now() + 60_000).toISOString(),
169
204
  request: {
170
205
  amount: '2000',
171
206
  currency: '0xabcd',
@@ -294,6 +329,7 @@ describe('createCredential', () => {
294
329
  realm,
295
330
  method: 'stripe',
296
331
  intent: 'charge',
332
+ expires: new Date(Date.now() + 60_000).toISOString(),
297
333
  request: {
298
334
  amount: '2000',
299
335
  currency: '0xabcd',
@@ -1,4 +1,5 @@
1
1
  import type * as Challenge from '../Challenge.js'
2
+ import * as Expires from '../Expires.js'
2
3
  import * as AcceptPayment from '../internal/AcceptPayment.js'
3
4
  import type * as Method from '../Method.js'
4
5
  import type * as z from '../zod.js'
@@ -106,6 +107,7 @@ export function create<
106
107
  )
107
108
 
108
109
  const { challenge, method: mi } = selected
110
+ if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
109
111
 
110
112
  const parsedContext =
111
113
  mi.context && context !== undefined ? mi.context.parse(context) : undefined
@@ -1,4 +1,4 @@
1
- import { Receipt } from 'mppx'
1
+ import { Errors, Receipt } from 'mppx'
2
2
  import { tempo } from 'mppx/client'
3
3
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
4
4
  import { createClient, defineChain } from 'viem'
@@ -335,14 +335,15 @@ const noopMethod = {
335
335
  } as any
336
336
 
337
337
  /** Builds a valid 402 response with a WWW-Authenticate header. */
338
- function make402(overrides?: { method?: string; intent?: string }) {
338
+ function make402(overrides?: { expires?: string; intent?: string; method?: string }) {
339
339
  const method = overrides?.method ?? 'test'
340
340
  const intent = overrides?.intent ?? 'test'
341
+ const expires = overrides?.expires ? `, expires="${overrides.expires}"` : ''
341
342
  const request = btoa(JSON.stringify({ amount: '1' }))
342
343
  .replace(/\+/g, '-')
343
344
  .replace(/\//g, '_')
344
345
  .replace(/=+$/, '')
345
- const header = `Payment id="abc", realm="test", method="${method}", intent="${intent}", request="${request}"`
346
+ const header = `Payment id="abc", realm="test", method="${method}", intent="${intent}", request="${request}"${expires}`
346
347
  return new Response(null, {
347
348
  status: 402,
348
349
  headers: { 'WWW-Authenticate': header },
@@ -603,6 +604,24 @@ describe('Fetch.from: init passthrough (non-402)', () => {
603
604
  })
604
605
 
605
606
  describe('Fetch.from: 402 retry path', () => {
607
+ test('rejects expired challenges before hooks or credential creation', async () => {
608
+ const createCredential = vi.fn(async () => 'credential')
609
+ const onChallenge = vi.fn(async () => undefined)
610
+ const mockFetch = vi.fn(async () =>
611
+ make402({ expires: new Date(Date.now() - 60_000).toISOString() }),
612
+ )
613
+ const fetch = Fetch.from({
614
+ fetch: mockFetch as typeof globalThis.fetch,
615
+ methods: [{ ...noopMethod, createCredential }],
616
+ onChallenge,
617
+ })
618
+
619
+ await expect(fetch('https://example.com/api')).rejects.toThrow(Errors.PaymentExpiredError)
620
+ expect(onChallenge).not.toHaveBeenCalled()
621
+ expect(createCredential).not.toHaveBeenCalled()
622
+ expect(mockFetch).toHaveBeenCalledTimes(1)
623
+ })
624
+
606
625
  test('strips context from init on retry', async () => {
607
626
  const calls: { init: RequestInit | undefined }[] = []
608
627
  let callCount = 0
@@ -1,4 +1,5 @@
1
1
  import * as Challenge from '../../Challenge.js'
2
+ import * as Expires from '../../Expires.js'
2
3
  import * as AcceptPayment from '../../internal/AcceptPayment.js'
3
4
  import type * as Method from '../../Method.js'
4
5
  import type * as z from '../../zod.js'
@@ -80,6 +81,7 @@ export function from<const methods extends readonly Method.AnyClient[]>(
80
81
  )
81
82
 
82
83
  const { challenge, method: mi } = selected
84
+ if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
83
85
 
84
86
  const onChallengeCredential = onChallenge
85
87
  ? await onChallenge(challenge, {
@@ -2,11 +2,12 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
2
  import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
3
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
4
4
  import { McpError } from '@modelcontextprotocol/sdk/types.js'
5
- import { Challenge, Mcp as core_Mcp } from 'mppx'
5
+ import { Challenge, Credential, Errors, Mcp as core_Mcp, Method } from 'mppx'
6
6
  import { tempo as tempo_client } from 'mppx/client'
7
7
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
8
+ import { Methods } from 'mppx/tempo'
8
9
  import { createClient } from 'viem'
9
- import { afterEach, beforeEach, describe, expect, test } from 'vp/test'
10
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vp/test'
10
11
  import { accounts, asset, chain, http, client as testClient } from '~test/tempo/viem.js'
11
12
 
12
13
  import * as McpServer_transport from '../server/Transport.js'
@@ -187,6 +188,42 @@ describe('McpClient.wrap', () => {
187
188
  'No compatible payment method. Server offers: unknown_method.charge. Client has: tempo.charge',
188
189
  )
189
190
  })
191
+
192
+ test('behavior: rejects expired challenges before creating credential', async () => {
193
+ const challenge = Challenge.fromMethod(Methods.charge, {
194
+ realm,
195
+ secretKey,
196
+ expires: new Date(Date.now() - 60_000).toISOString(),
197
+ request: {
198
+ amount: '1',
199
+ currency: asset,
200
+ decimals: 6,
201
+ recipient: accounts[0].address,
202
+ },
203
+ })
204
+
205
+ server.registerTool('expired_tool', { description: 'Tool' }, async () => {
206
+ throw new McpError(core_Mcp.paymentRequiredCode, 'Payment Required', {
207
+ httpStatus: 402,
208
+ challenges: [challenge],
209
+ })
210
+ })
211
+
212
+ const createCredential = vi.fn(async ({ challenge }) =>
213
+ Credential.serialize({
214
+ challenge,
215
+ payload: { signature: '0xsignature', type: 'transaction' },
216
+ }),
217
+ )
218
+ const mcp = McpClient.wrap(client, {
219
+ methods: [Method.toClient(Methods.charge, { createCredential })],
220
+ })
221
+
222
+ await expect(mcp.callTool({ name: 'expired_tool', arguments: {} })).rejects.toThrow(
223
+ Errors.PaymentExpiredError,
224
+ )
225
+ expect(createCredential).not.toHaveBeenCalled()
226
+ })
190
227
  })
191
228
 
192
229
  describe('isPaymentRequiredError', () => {
@@ -3,6 +3,7 @@ import type { McpError } from '@modelcontextprotocol/sdk/types.js'
3
3
 
4
4
  import type * as Challenge from '../../Challenge.js'
5
5
  import * as Credential from '../../Credential.js'
6
+ import * as Expires from '../../Expires.js'
6
7
  import * as AcceptPayment from '../../internal/AcceptPayment.js'
7
8
  import * as core_Mcp from '../../Mcp.js'
8
9
  import type * as Method from '../../Method.js'
@@ -186,6 +187,8 @@ async function createCredential<methods extends readonly Method.AnyClient[]>(
186
187
  `No method found for "${challenge.method}.${challenge.intent}". Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
187
188
  )
188
189
 
190
+ if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
191
+
189
192
  const parsedContext = mi.context && context !== undefined ? mi.context.parse(context) : undefined
190
193
  return mi.createCredential(
191
194
  parsedContext !== undefined ? { challenge, context: parsedContext } : ({ challenge } as never),
@@ -180,7 +180,7 @@ describe('scope binding', () => {
180
180
  expect(challengeResponse.status).toBe(402)
181
181
 
182
182
  const challenge = Challenge.fromResponse(challengeResponse)
183
- expect(challenge.opaque).toEqual({ _mppx_scope: 'GET /alpha/:id' })
183
+ expect(challenge.opaque).toBe('eyJfbXBweF9zY29wZSI6IkdFVCAvYWxwaGEvOmlkIn0')
184
184
 
185
185
  const credential = Credential.from({ challenge, payload: { token: 'valid' } })
186
186
  const replay = await fetch(`${server.url}/beta/1`, {
@@ -207,7 +207,7 @@ describe('scope binding', () => {
207
207
  expect(challengeResponse.status).toBe(402)
208
208
 
209
209
  const challenge = Challenge.fromResponse(challengeResponse)
210
- expect(challenge.opaque).toEqual({ _mppx_scope: 'shared-scope' })
210
+ expect(challenge.opaque).toBe('eyJfbXBweF9zY29wZSI6InNoYXJlZC1zY29wZSJ9')
211
211
 
212
212
  const credential = Credential.from({ challenge, payload: { token: 'valid' } })
213
213
  const replay = await fetch(`${server.url}/beta/2`, {
@@ -771,7 +771,7 @@ describe('create', () => {
771
771
  expect(challengeResponse.status).toBe(402)
772
772
 
773
773
  const challenge = Challenge.fromResponse(challengeResponse)
774
- expect(challenge.opaque).toEqual({ _mppx_scope: 'GET /api/v1/alpha' })
774
+ expect(challenge.opaque).toBe('eyJfbXBweF9zY29wZSI6IkdFVCAvYXBpL3YxL2FscGhhIn0')
775
775
 
776
776
  const credential = Credential.from({ challenge, payload: { token: 'valid' } })
777
777
  const replay = await fetch(`${proxyServer.url}/api/v1/beta`, {
@@ -813,7 +813,7 @@ describe('create', () => {
813
813
  expect(challengeResponse.status).toBe(402)
814
814
 
815
815
  const challenge = Challenge.fromResponse(challengeResponse)
816
- expect(challenge.opaque).toEqual({ _mppx_scope: 'shared-scope' })
816
+ expect(challenge.opaque).toBe('eyJfbXBweF9zY29wZSI6InNoYXJlZC1zY29wZSJ9')
817
817
 
818
818
  const credential = Credential.from({ challenge, payload: { token: 'valid' } })
819
819
  const replay = await fetch(`${proxyServer.url}/api/v1/beta`, {