mppx 0.6.15 → 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 (49) hide show
  1. package/CHANGELOG.md +7 -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/server/Mppx.d.ts.map +1 -1
  11. package/dist/server/Mppx.js +19 -9
  12. package/dist/server/Mppx.js.map +1 -1
  13. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  14. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  15. package/dist/stripe/server/internal/html.gen.js +1 -1
  16. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  17. package/dist/tempo/Proof.d.ts +5 -0
  18. package/dist/tempo/Proof.d.ts.map +1 -1
  19. package/dist/tempo/Proof.js +5 -1
  20. package/dist/tempo/Proof.js.map +1 -1
  21. package/dist/tempo/internal/proof.d.ts +2 -2
  22. package/dist/tempo/internal/proof.d.ts.map +1 -1
  23. package/dist/tempo/internal/proof.js +2 -2
  24. package/dist/tempo/internal/proof.js.map +1 -1
  25. package/dist/tempo/server/Charge.d.ts.map +1 -1
  26. package/dist/tempo/server/Charge.js +57 -17
  27. package/dist/tempo/server/Charge.js.map +1 -1
  28. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  29. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  30. package/dist/tempo/server/internal/html.gen.js +1 -1
  31. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  32. package/package.json +2 -2
  33. package/src/Challenge.test.ts +25 -9
  34. package/src/Challenge.ts +21 -10
  35. package/src/Credential.test.ts +64 -2
  36. package/src/Credential.ts +35 -19
  37. package/src/middlewares/hono.test.ts +2 -2
  38. package/src/proxy/Proxy.test.ts +2 -2
  39. package/src/server/Mppx.test.ts +28 -8
  40. package/src/server/Mppx.ts +23 -9
  41. package/src/stripe/server/internal/html.gen.ts +1 -1
  42. package/src/tempo/Proof.test-d.ts +4 -0
  43. package/src/tempo/Proof.test.ts +9 -0
  44. package/src/tempo/Proof.ts +6 -1
  45. package/src/tempo/internal/proof.test.ts +4 -4
  46. package/src/tempo/internal/proof.ts +2 -2
  47. package/src/tempo/server/Charge.test.ts +476 -0
  48. package/src/tempo/server/Charge.ts +61 -17
  49. 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,g+5tBAAg+5tB,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.15",
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": {
@@ -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,
@@ -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`, {
@@ -588,7 +588,7 @@ describe('request handler', () => {
588
588
  method: rest.method,
589
589
  intent: rest.intent,
590
590
  request: rest.request,
591
- ...(rest.opaque && { meta: rest.opaque }),
591
+ ...(rest.meta && { meta: rest.meta }),
592
592
  })
593
593
 
594
594
  const credential = Credential.from({
@@ -1413,8 +1413,8 @@ describe('compose', () => {
1413
1413
 
1414
1414
  const challenges = Challenge.fromResponseList(firstResult.challenge)
1415
1415
  expect(challenges).toHaveLength(2)
1416
- expect(challenges[0]?.opaque).toEqual({ route: 'a' })
1417
- expect(challenges[1]?.opaque).toEqual({ route: 'b' })
1416
+ expect(challenges[0]?.opaque).toBe('eyJyb3V0ZSI6ImEifQ')
1417
+ expect(challenges[1]?.opaque).toBe('eyJyb3V0ZSI6ImIifQ')
1418
1418
 
1419
1419
  const secondChallenge = challenges[1]!
1420
1420
  const credential = Credential.from({
@@ -2052,8 +2052,8 @@ describe('cross-route credential replay via scope binding flaw', () => {
2052
2052
  const routeAChallenge = Challenge.fromResponse(routeAChallengeResult.challenge)
2053
2053
  const routeBChallenge = Challenge.fromResponse(routeBChallengeResult.challenge)
2054
2054
 
2055
- expect(routeAChallenge.opaque).toEqual({ route: 'a' })
2056
- expect(routeBChallenge.opaque).toEqual({ route: 'b' })
2055
+ expect(routeAChallenge.opaque).toBe('eyJyb3V0ZSI6ImEifQ')
2056
+ expect(routeBChallenge.opaque).toBe('eyJyb3V0ZSI6ImIifQ')
2057
2057
 
2058
2058
  const credential = Credential.from({
2059
2059
  challenge: routeAChallenge,
@@ -2133,7 +2133,7 @@ describe('cross-route credential replay via scope binding flaw', () => {
2133
2133
  if (routeAChallengeResult.status !== 402) throw new Error()
2134
2134
 
2135
2135
  const routeAChallenge = Challenge.fromResponse(routeAChallengeResult.challenge)
2136
- expect(routeAChallenge.opaque).toEqual({ _mppx_scope: 'GET /a' })
2136
+ expect(routeAChallenge.opaque).toBe('eyJfbXBweF9zY29wZSI6IkdFVCAvYSJ9')
2137
2137
 
2138
2138
  const credential = Credential.from({
2139
2139
  challenge: routeAChallenge,
@@ -3129,7 +3129,7 @@ describe('challenge', () => {
3129
3129
  })
3130
3130
 
3131
3131
  expect(challenge.description).toBe('Order #123')
3132
- expect(challenge.opaque).toEqual({ checkout_id: 'chk_abc' })
3132
+ expect(challenge.meta).toEqual({ checkout_id: 'chk_abc' })
3133
3133
  })
3134
3134
 
3135
3135
  test('challenge binds scope via reserved opaque metadata', async () => {
@@ -3144,7 +3144,7 @@ describe('challenge', () => {
3144
3144
  scope: 'GET /premium',
3145
3145
  })
3146
3146
 
3147
- expect(challenge.opaque).toEqual({ _mppx_scope: 'GET /premium' })
3147
+ expect(challenge.meta).toEqual({ _mppx_scope: 'GET /premium' })
3148
3148
  })
3149
3149
 
3150
3150
  test('scope throws when it conflicts with reserved meta scope', async () => {
@@ -3420,6 +3420,26 @@ describe('verifyCredential', () => {
3420
3420
  expect(receipt.method).toBe('alpha')
3421
3421
  })
3422
3422
 
3423
+ test('verifies a parsed raw-opaque credential object when the expected scope matches', async () => {
3424
+ const mppx = Mppx.create({
3425
+ methods: [alphaChargeServer],
3426
+ realm,
3427
+ secretKey,
3428
+ })
3429
+
3430
+ const challenge = await mppx.challenge.alpha.charge({
3431
+ ...challengeOpts,
3432
+ scope: 'GET /premium',
3433
+ })
3434
+ const rawChallenge = Challenge.deserialize(Challenge.serialize(challenge))
3435
+ const credential = Credential.from({ challenge: rawChallenge, payload: { token: 'valid' } })
3436
+
3437
+ const receipt = await mppx.verifyCredential(credential, { scope: 'GET /premium' })
3438
+
3439
+ expect(receipt.status).toBe('success')
3440
+ expect(receipt.method).toBe('alpha')
3441
+ })
3442
+
3423
3443
  test('rejects a credential when the expected scope mismatches', async () => {
3424
3444
  const mppx = Mppx.create({
3425
3445
  methods: [alphaChargeServer],
@@ -279,7 +279,9 @@ export function create<
279
279
  input: string | Credential.Credential,
280
280
  options?: VerifyCredentialOptions,
281
281
  ): Promise<Receipt.Receipt> {
282
- const credential = typeof input === 'string' ? Credential.deserialize(input) : input
282
+ const credential = hydrateCredentialMeta(
283
+ typeof input === 'string' ? Credential.deserialize(input) : input,
284
+ )
283
285
 
284
286
  // HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
285
287
  if (!Challenge.verify(credential.challenge, { secretKey: secretKey! }))
@@ -307,7 +309,7 @@ export function create<
307
309
 
308
310
  const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope })
309
311
 
310
- if (options?.scope !== undefined && Scope.read(credential.challenge.opaque) !== options.scope) {
312
+ if (options?.scope !== undefined && Scope.read(credential.challenge.meta) !== options.scope) {
311
313
  throw new Errors.InvalidChallengeError({
312
314
  id: credential.challenge.id,
313
315
  reason: "credential scope does not match this route's requirements",
@@ -438,10 +440,8 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
438
440
  // Extract credential once — getCredential may have side effects (e.g. SSE transports).
439
441
  const [credential, credentialError] = (() => {
440
442
  try {
441
- return [
442
- transport.getCredential(input) as Credential.Credential | null,
443
- undefined,
444
- ] as const
443
+ const credential = transport.getCredential(input) as Credential.Credential | null
444
+ return [credential ? hydrateCredentialMeta(credential) : null, undefined] as const
445
445
  } catch (e) {
446
446
  return [null, e as Error] as const
447
447
  }
@@ -851,7 +851,7 @@ function getPinnedChallengeMismatch(
851
851
  if (actualChallenge[field] !== expectedChallenge[field]) return field
852
852
  }
853
853
 
854
- if (!opaqueValuesMatch(expectedChallenge.opaque, actualChallenge.opaque)) return 'opaque'
854
+ if (!opaqueValuesMatch(expectedChallenge.meta, actualChallenge.meta)) return 'opaque'
855
855
 
856
856
  return getPinnedRequestBindingMismatch(
857
857
  expectedChallenge.request as Record<string, unknown>,
@@ -944,6 +944,20 @@ function opaqueValuesMatch(
944
944
  return isDeepStrictEqual(expected, actual)
945
945
  }
946
946
 
947
+ function hydrateCredentialMeta<payload>(
948
+ credential: Credential.Credential<payload>,
949
+ ): Credential.Credential<payload> {
950
+ const { challenge } = credential
951
+ if (challenge.meta !== undefined || challenge.opaque === undefined) return credential
952
+ return {
953
+ ...credential,
954
+ challenge: {
955
+ ...challenge,
956
+ meta: PaymentRequest.deserialize(challenge.opaque) as Record<string, string>,
957
+ },
958
+ }
959
+ }
960
+
947
961
  type CoreBinding = {
948
962
  [field in CoreBindingField]?: string
949
963
  }
@@ -1112,7 +1126,7 @@ export function compose(
1112
1126
  // Parse the credential to find method+intent for dispatch.
1113
1127
  let credential: Credential.Credential | undefined
1114
1128
  try {
1115
- credential = Credential.deserialize(paymentHeader)
1129
+ credential = hydrateCredentialMeta(Credential.deserialize(paymentHeader))
1116
1130
  } catch {}
1117
1131
 
1118
1132
  if (credential) {
@@ -1132,7 +1146,7 @@ export function compose(
1132
1146
  if (!canonical) return true
1133
1147
  return (
1134
1148
  !getPinnedRequestBindingMismatch(canonical, credReq) &&
1135
- opaqueValuesMatch(internal.meta, credential.challenge.opaque)
1149
+ opaqueValuesMatch(internal.meta, credential.challenge.meta)
1136
1150
  )
1137
1151
  })
1138
1152